diff --git a/.claude/agents/raise-pull-request.md b/.claude/agents/raise-pull-request.md new file mode 100644 index 00000000000..e466df6fa7e --- /dev/null +++ b/.claude/agents/raise-pull-request.md @@ -0,0 +1,225 @@ +--- +name: raise-pull-request +description: | + Use this agent when creating a pull request for the Home Assistant core repository after completing implementation work. This agent automates the PR creation process including running tests, formatting checks, and proper checkbox handling. +model: inherit +color: green +tools: Read, Bash, Grep, Glob +--- + +You are an expert at creating pull requests for the Home Assistant core repository. You will automate the PR creation process with proper verification, formatting, testing, and checkbox handling. + +**Execute each step in order. Do not skip steps.** + +## Step 1: Gather Information + +Run these commands in parallel to analyze the changes: + +```bash +# Get current branch and remote +git branch --show-current +git remote -v | grep push + +# Determine the best available dev reference +if git rev-parse --verify --quiet upstream/dev >/dev/null; then + BASE_REF="upstream/dev" +elif git rev-parse --verify --quiet origin/dev >/dev/null; then + BASE_REF="origin/dev" +elif git rev-parse --verify --quiet dev >/dev/null; then + BASE_REF="dev" +else + echo "Could not find upstream/dev, origin/dev, or local dev" + exit 1 +fi + +BASE_SHA="$(git merge-base "$BASE_REF" HEAD)" +echo "BASE_REF=$BASE_REF" +echo "BASE_SHA=$BASE_SHA" + +# Get commit info for this branch vs dev +git log "${BASE_SHA}..HEAD" --oneline + +# Check what files changed +git diff "${BASE_SHA}..HEAD" --name-only + +# Check if test files were added/modified +git diff "${BASE_SHA}..HEAD" --name-only | grep -E "^tests/.*\.py$" || echo "NO_TESTS_CHANGED" + +# Check if manifest.json changed +git diff "${BASE_SHA}..HEAD" --name-only | grep "manifest.json" || echo "NO_MANIFEST_CHANGED" +``` + +From the file paths, extract the **integration domain** from `homeassistant/components/{integration}/` or `tests/components/{integration}/`. + +**Track results:** +- `BASE_REF`: the dev reference used for comparison +- `BASE_SHA`: the merge-base commit used for diff-based checks +- `TESTS_CHANGED`: true if test files were added or modified +- `MANIFEST_CHANGED`: true if manifest.json was modified + +**If no suitable dev reference is available, STOP and tell the user to fetch `upstream/dev`, `origin/dev`, or a local `dev` branch before continuing.** + +## Step 2: Run Code Quality Checks + +Run `prek` to perform code quality checks (formatting, linting, hassfest, etc.) on the files changed since `BASE_SHA`: + +```bash +prek run --from-ref "$BASE_SHA" --to-ref HEAD +``` + +**Track results:** +- `PREK_PASSED`: true if `prek run` exits with code 0 + +**If `prek` fails or is not available, STOP and report the failure to the user. Do not proceed with PR creation. If the failure appears to be an environment setup issue (e.g., missing tools, command not found, venv not activated), also point the user to https://developers.home-assistant.io/docs/development_environment.** + +## Step 3: Stage Any Changes from Checks + +If `prek` made any formatting or generated file changes, stage and commit them as a separate commit: + +```bash +git status --porcelain +# If changes exist: +git add -A +git commit -m "Apply prek formatting and generated file updates" +``` + +## Step 4: Run Tests + +Run pytest for the specific integration: + +```bash +pytest tests/components/{integration} \ + --timeout=60 \ + --durations-min=1 \ + --durations=0 \ + -q +``` + +**Track results:** +- `TESTS_PASSED`: true if pytest exits with code 0 + +**If tests fail, STOP and report the failures to the user. Do not proceed with PR creation.** + +## Step 5: Identify PR Metadata + +Write a release-note-style PR title summarizing the change. The title becomes the release notes entry, so it should be a complete sentence fragment describing what changed in imperative mood. + +**PR Title Examples by Type:** +| Type | Example titles | +|------|----------------| +| Bugfix | `Fix Hikvision NVR binary sensors not being detected` | +| | `Fix JSON serialization of time objects in anthropic tool results` | +| | `Fix config flow bug in Tesla Fleet` | +| Dependency | `Bump eheimdigital to 1.5.0` | +| | `Bump python-otbr-api to 2.7.1` | +| New feature | `Add asyncio-level timeout to Backblaze B2 uploads` | +| | `Add Nettleie optimization option` | +| Code quality | `Add exception translations to Teslemetry` | +| | `Improve test coverage of Tesla Fleet` | +| | `Refactor adguard tests to use proper fixtures for mocking` | +| | `Simplify entity init in Proxmox` | + +## Step 6: Verify Development Checklist + +Check each item from the [development checklist](https://developers.home-assistant.io/docs/development_checklist/): + +| Item | How to verify | +|------|---------------| +| External libraries on PyPI | Check manifest.json requirements - all should be PyPI packages | +| Dependencies in requirements_all.txt | Only if dependency declarations changed (the `requirements` field in `manifest.json` or `requirements_all.txt`), run `python -m script.gen_requirements_all` | +| Codeowners updated | If this is a new integration, ensure its `manifest.json` includes a `codeowners` field with one or more GitHub usernames | +| No commented out code | Visually scan the diff for blocks of commented-out code | + +**Track results:** +- `NO_COMMENTED_CODE`: true if no blocks of commented-out code found in the diff +- `DEPENDENCIES_CHANGED`: true if the diff changes the `requirements` field in `manifest.json` or changes `requirements_all.txt` +- `REQUIREMENTS_UPDATED`: true if `DEPENDENCIES_CHANGED` is true and requirements_all.txt was regenerated successfully; not applicable if `DEPENDENCIES_CHANGED` is false +- `CHECKLIST_PASSED`: true if all items above pass + +## Step 7: Determine Type of Change + +Select exactly ONE based on the changes. Mark the selected type with `[x]` and all others with `[ ]` (space): + +| Type | Condition | +|------|-----------| +| Dependency upgrade | Only manifest.json/requirements changes | +| Bugfix | Fixes broken behavior, no new features | +| New integration | New folder in components/ | +| New feature | Adds capability to existing integration | +| Deprecation | Adds deprecation warnings for future breaking change | +| Breaking change | Removes or changes existing functionality | +| Code quality | Only refactoring or test additions, no functional change | + +**Track results:** +- `CHANGE_TYPE`: the selected type (e.g., "Bugfix", "New feature", "Code quality", etc.) + +**Important:** All seven type options must remain in the PR body. Only the selected type gets `[x]`, all others get `[ ]`. + +## Step 8: Determine Checkbox States + +Based on the verification steps above, determine checkbox states: + +| Checkbox | Condition to tick | +|----------|-------------------| +| The code change is tested and works locally | Leave unchecked for the contributor to verify manually (this refers to manual testing, not unit tests) | +| Local tests pass | Tick only if `TESTS_PASSED` is true | +| I understand the code I am submitting and can explain how it works | Leave unchecked for the contributor to review and set manually | +| There is no commented out code | Tick only if `NO_COMMENTED_CODE` is true | +| Development checklist | Tick only if `CHECKLIST_PASSED` is true | +| Perfect PR recommendations | Tick only if the PR affects a single integration or closely related modules, represents one primary type of change, and has a clear, self-contained scope | +| Formatted using Ruff | Tick only if `PREK_PASSED` is true | +| Tests have been added | Tick only if `TESTS_CHANGED` is true AND the changes exercise new or changed functionality (not only cosmetic test changes) | +| Documentation added/updated | Tick if documentation PR created (or not applicable) | +| Manifest file fields filled out | Tick if `PREK_PASSED` is true (or not applicable) | +| Dependencies in requirements_all.txt | Tick only if `DEPENDENCIES_CHANGED` is false, or if `DEPENDENCIES_CHANGED` is true and `REQUIREMENTS_UPDATED` is true | +| Dependency changelog linked | Tick if dependency changelog linked in PR description (or not applicable) | +| Any generated code has been carefully reviewed | Leave unchecked for the contributor to review and set manually | + +## Step 9: Breaking Change Section + +**If `CHANGE_TYPE` is NOT "Breaking change" or "Deprecation": REMOVE the entire "## Breaking change" section from the PR body (including the heading).** + +If `CHANGE_TYPE` IS "Breaking change" or "Deprecation", keep the `## Breaking change` section and describe: +- What breaks +- How users can fix it +- Why it was necessary + +## Step 10: Push Branch and Create PR + +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 \ + --draft \ + --title "TITLE_HERE" \ + --body "$(cat <<'EOF' +BODY_HERE +EOF +)" +``` + +### PR Body Template + +Read the PR template from `.github/PULL_REQUEST_TEMPLATE.md` and use it as the basis for the PR body. **Do not hardcode the template — always read it from the file to stay in sync with upstream changes.** + +Use any HTML comments (``) in the template as guidance to understand what to fill in. For the final PR body sent to GitHub, keep the template text intact — do not delete any text from the template unless it explicitly instructs removal (e.g., the breaking change section when not applicable). Then fill in the sections: + +1. **Breaking change section**: If the type is NOT "Breaking change" or "Deprecation", remove the entire `## Breaking change` section (heading and body). Otherwise, describe what breaks, how users can fix it, and why. +2. **Proposed change section**: Fill in a description of the change extracted from commit messages. +3. **Type of change**: Check exactly ONE checkbox matching the determined type from Step 7. Leave all others unchecked. +4. **Additional information**: Fill in any related issue numbers if known. +5. **Checklist**: Check boxes based on the conditions in Step 8. Leave manual-verification boxes unchecked for the contributor. + +**Important:** Preserve all template structure, options, and link references exactly as they appear in the file — only modify checkbox states and fill in content sections. + +## Step 11: Report Result + +Provide the user with: +1. **PR URL** - The created pull request link +2. **Verification Summary** - Which checks passed/failed +3. **Unchecked Items** - List any checkboxes left unchecked and why +4. **User Action Required** - Remind user to: + - Review and set manual-verification checkboxes ("I understand the code..." and "Any generated code...") as applicable + - Consider reviewing two other open PRs + - Add any related issue numbers if applicable diff --git a/.claude/skills/github-pr-reviewer/SKILL.md b/.claude/skills/github-pr-reviewer/SKILL.md index 3d3586eb0f4..3e4fa4aa49b 100644 --- a/.claude/skills/github-pr-reviewer/SKILL.md +++ b/.claude/skills/github-pr-reviewer/SKILL.md @@ -1,18 +1,10 @@ --- name: github-pr-reviewer -description: Review a GitHub pull request and provide feedback comments. Use when the user says "review the current PR" or asks to review a specific PR. +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 -## Preparation: -- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'. -- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP. - - Do NOT attempt any workarounds. - - Do NOT proceed with the review. - - ALERT about the failure and WAIT for instructions. - - This is a hard requirement - no exceptions. - ## 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. @@ -35,12 +27,13 @@ description: Review a GitHub pull request and provide feedback comments. Use whe - No need to highlight things that are already good. ## Output format: -- List specific comments for each file/line that needs attention +- 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 + - [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/.claude/skills/ha-integration-knowledge/SKILL.md b/.claude/skills/ha-integration-knowledge/SKILL.md new file mode 100644 index 00000000000..cd25d3127ef --- /dev/null +++ b/.claude/skills/ha-integration-knowledge/SKILL.md @@ -0,0 +1,47 @@ +--- +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. +--- + +## 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()`. + +## 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/.claude/skills/ha-integration-knowledge/platform-diagnostics.md b/.claude/skills/ha-integration-knowledge/platform-diagnostics.md new file mode 100644 index 00000000000..8d3fa73cd97 --- /dev/null +++ b/.claude/skills/ha-integration-knowledge/platform-diagnostics.md @@ -0,0 +1,6 @@ +# Integration Diagnostics + +Platform exists as `homeassistant/components//diagnostics.py`. + +- **Required**: Implement diagnostic data collection +- **Security**: Never expose passwords, tokens, or sensitive coordinates diff --git a/.claude/skills/ha-integration-knowledge/platform-repairs.md b/.claude/skills/ha-integration-knowledge/platform-repairs.md new file mode 100644 index 00000000000..269db92239b --- /dev/null +++ b/.claude/skills/ha-integration-knowledge/platform-repairs.md @@ -0,0 +1,21 @@ +# Repairs platform + +Platform exists as `homeassistant/components//repairs.py`. + +- **Actionable Issues Required**: All repair issues must be actionable for end users +- **Issue Content Requirements**: + - Clearly explain what is happening + - Provide specific steps users need to take to resolve the issue + - Use friendly, helpful language + - Include relevant context (device names, error details, etc.) +- **String Content Must Include**: + - What the problem is + - Why it matters + - Exact steps to resolve (numbered list when multiple steps) + - What to expect after following the steps +- **Avoid Vague Instructions**: Don't just say "update firmware" - provide specific steps +- **Severity Guidelines**: + - `CRITICAL`: Reserved for extreme scenarios only + - `ERROR`: Requires immediate user attention + - `WARNING`: Indicates future potential breakage +- Only create issues for problems users can potentially resolve diff --git a/.claude/skills/integrations/SKILL.md b/.claude/skills/integrations/SKILL.md deleted file mode 100644 index 2bf861a9c8b..00000000000 --- a/.claude/skills/integrations/SKILL.md +++ /dev/null @@ -1,786 +0,0 @@ ---- -name: Home Assistant 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. ---- - -### File Locations -- **Integration code**: `./homeassistant/components//` -- **Integration tests**: `./tests/components//` - -## Integration Templates - -### Standard Integration Structure -``` -homeassistant/components/my_integration/ -├── __init__.py # Entry point with async_setup_entry -├── manifest.json # Integration metadata and dependencies -├── const.py # Domain and constants -├── config_flow.py # UI configuration flow -├── coordinator.py # Data update coordinator (if needed) -├── entity.py # Base entity class (if shared patterns) -├── sensor.py # Sensor platform -├── strings.json # User-facing text and translations -├── services.yaml # Service definitions (if applicable) -└── quality_scale.yaml # Quality scale rule status -``` - -An integration can have platforms as needed (e.g., `sensor.py`, `switch.py`, etc.). 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 - -### Minimal Integration Checklist -- [ ] `manifest.json` with required fields (domain, name, codeowners, etc.) -- [ ] `__init__.py` with `async_setup_entry` and `async_unload_entry` -- [ ] `config_flow.py` with UI configuration support -- [ ] `const.py` with `DOMAIN` constant -- [ ] `strings.json` with at least config flow text -- [ ] Platform files (`sensor.py`, etc.) as needed -- [ ] `quality_scale.yaml` with rule status tracking - -## Integration Quality Scale - -Home Assistant uses an Integration Quality Scale to ensure code quality and consistency. The quality level determines which rules apply: - -### Quality Scale Levels -- **Bronze**: Basic requirements (ALL Bronze rules are mandatory) -- **Silver**: Enhanced functionality -- **Gold**: Advanced features -- **Platinum**: Highest quality standards - -### Quality Scale Progression -- **Bronze → Silver**: Add entity unavailability, parallel updates, auth flows -- **Silver → Gold**: Add device management, diagnostics, translations -- **Gold → Platinum**: Add strict typing, async dependencies, websession injection - -### 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 - -### Example `quality_scale.yaml` Structure -```yaml -rules: - # Bronze (mandatory) - config-flow: done - entity-unique-id: done - action-setup: - status: exempt - comment: Integration does not register custom actions. - - # Silver (if targeting Silver+) - entity-unavailable: done - parallel-updates: done - - # Gold (if targeting Gold+) - devices: done - diagnostics: done - - # Platinum (if targeting Platinum) - strict-typing: done -``` - -**When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules. - -## Code Organization - -### Core Locations -- Shared constants: `homeassistant/const.py` (use these instead of hardcoding) -- Integration structure: - - `homeassistant/components/{domain}/const.py` - Constants - - `homeassistant/components/{domain}/models.py` - Data models - - `homeassistant/components/{domain}/coordinator.py` - Update coordinator - - `homeassistant/components/{domain}/config_flow.py` - Configuration flow - - `homeassistant/components/{domain}/{platform}.py` - Platform implementations - -### Common Modules -- **coordinator.py**: Centralize data fetching logic - ```python - class MyCoordinator(DataUpdateCoordinator[MyData]): - def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None: - super().__init__( - hass, - logger=LOGGER, - name=DOMAIN, - update_interval=timedelta(minutes=1), - config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended - ) - ``` -- **entity.py**: Base entity definitions to reduce duplication - ```python - class MyEntity(CoordinatorEntity[MyCoordinator]): - _attr_has_entity_name = True - ``` - -### Runtime Data Storage -- **Use ConfigEntry.runtime_data**: Store non-persistent runtime data - ```python - type MyIntegrationConfigEntry = ConfigEntry[MyClient] - - async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool: - client = MyClient(entry.data[CONF_HOST]) - entry.runtime_data = client - ``` - -### Manifest Requirements -- **Required Fields**: `domain`, `name`, `codeowners`, `integration_type`, `documentation`, `requirements` -- **Integration Types**: `device`, `hub`, `service`, `system`, `helper` -- **IoT Class**: Always specify connectivity method (e.g., `cloud_polling`, `local_polling`, `local_push`) -- **Discovery Methods**: Add when applicable: `zeroconf`, `dhcp`, `bluetooth`, `ssdp`, `usb` -- **Dependencies**: Include platform dependencies (e.g., `application_credentials`, `bluetooth_adapters`) - -### Config Flow Patterns -- **Version Control**: Always set `VERSION = 1` and `MINOR_VERSION = 1` -- **Unique ID Management**: - ```python - await self.async_set_unique_id(device_unique_id) - self._abort_if_unique_id_configured() - ``` -- **Error Handling**: Define errors in `strings.json` under `config.error` -- **Step Methods**: Use standard naming (`async_step_user`, `async_step_discovery`, etc.) - -### Integration Ownership -- **manifest.json**: Add GitHub usernames to `codeowners`: - ```json - { - "domain": "my_integration", - "name": "My Integration", - "codeowners": ["@me"] - } - ``` - -### Async Dependencies (Platinum) -- **Requirement**: All dependencies must use asyncio -- Ensures efficient task handling without thread context switching - -### WebSession Injection (Platinum) -- **Pass WebSession**: Support passing web sessions to dependencies - ```python - async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool: - """Set up integration from config entry.""" - client = MyClient(entry.data[CONF_HOST], async_get_clientsession(hass)) - ``` -- For cookies: Use `async_create_clientsession` (aiohttp) or `create_async_httpx_client` (httpx) - -### Data Update Coordinator -- **Standard Pattern**: Use for efficient data management - ```python - class MyCoordinator(DataUpdateCoordinator): - def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None: - super().__init__( - hass, - logger=LOGGER, - name=DOMAIN, - update_interval=timedelta(minutes=5), - config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended - ) - self.client = client - - async def _async_update_data(self): - try: - return await self.client.fetch_data() - except ApiError as err: - raise UpdateFailed(f"API communication error: {err}") - ``` -- **Error Types**: Use `UpdateFailed` for API errors, `ConfigEntryAuthFailed` for auth issues -- **Config Entry**: Always pass `config_entry` parameter to coordinator - it's accepted and recommended - -## Integration Guidelines - -### Configuration Flow -- **UI Setup Required**: All integrations must support configuration via UI -- **Manifest**: Set `"config_flow": true` in `manifest.json` -- **Data Storage**: - - Connection-critical config: Store in `ConfigEntry.data` - - Non-critical settings: Store in `ConfigEntry.options` -- **Validation**: Always validate user input before creating entries -- **Config Entry Naming**: - - ❌ 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 in config flow -- **Connection Testing**: Test device/service connection during config flow: - ```python - try: - await client.get_data() - except MyException: - errors["base"] = "cannot_connect" - ``` -- **Duplicate Prevention**: Prevent duplicate configurations: - ```python - # Using unique ID - await self.async_set_unique_id(identifier) - self._abort_if_unique_id_configured() - - # Using unique data - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) - ``` - -### Reauthentication Support -- **Required Method**: Implement `async_step_reauth` in config flow -- **Credential Updates**: Allow users to update credentials without re-adding -- **Validation**: Verify account matches existing unique ID: - ```python - await self.async_set_unique_id(user_id) - self._abort_if_unique_id_mismatch(reason="wrong_account") - return self.async_update_reload_and_abort( - self._get_reauth_entry(), - data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]} - ) - ``` - -### Reconfiguration Flow -- **Purpose**: Allow configuration updates without removing device -- **Implementation**: Add `async_step_reconfigure` method -- **Validation**: Prevent changing underlying account with `_abort_if_unique_id_mismatch` - -### Device Discovery -- **Manifest Configuration**: Add discovery method (zeroconf, dhcp, etc.) - ```json - { - "zeroconf": ["_mydevice._tcp.local."] - } - ``` -- **Discovery Handler**: Implement appropriate `async_step_*` method: - ```python - async def async_step_zeroconf(self, discovery_info): - """Handle zeroconf discovery.""" - await self.async_set_unique_id(discovery_info.properties["serialno"]) - self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) - ``` -- **Network Updates**: Use discovery to update dynamic IP addresses - -### Network Discovery Implementation -- **Zeroconf/mDNS**: Use async instances - ```python - aiozc = await zeroconf.async_get_async_instance(hass) - ``` -- **SSDP Discovery**: Register callbacks with cleanup - ```python - entry.async_on_unload( - ssdp.async_register_callback( - hass, _async_discovered_device, - {"st": "urn:schemas-upnp-org:device:ZonePlayer:1"} - ) - ) - ``` - -### Bluetooth Integration -- **Manifest Dependencies**: Add `bluetooth_adapters` to dependencies -- **Connectable**: Set `"connectable": true` for connection-required devices -- **Scanner Usage**: Always use shared scanner instance - ```python - scanner = bluetooth.async_get_scanner() - entry.async_on_unload( - bluetooth.async_register_callback( - hass, _async_discovered_device, - {"service_uuid": "example_uuid"}, - bluetooth.BluetoothScanningMode.ACTIVE - ) - ) - ``` -- **Connection Handling**: Never reuse `BleakClient` instances, use 10+ second timeouts - -### Setup Validation -- **Test Before Setup**: Verify integration can be set up in `async_setup_entry` -- **Exception Handling**: - - `ConfigEntryNotReady`: Device offline or temporary failure - - `ConfigEntryAuthFailed`: Authentication issues - - `ConfigEntryError`: Unresolvable setup problems - -### Config Entry Unloading -- **Required**: Implement `async_unload_entry` for runtime removal/reload -- **Platform Unloading**: Use `hass.config_entries.async_unload_platforms` -- **Cleanup**: Register callbacks with `entry.async_on_unload`: - ```python - async def async_unload_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool: - """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - entry.runtime_data.listener() # Clean up resources - return unload_ok - ``` - -### Service Actions -- **Registration**: Register all service actions in `async_setup`, NOT in `async_setup_entry` -- **Validation**: Check config entry existence and loaded state: - ```python - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - async def service_action(call: ServiceCall) -> ServiceResponse: - if not (entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY_ID])): - raise ServiceValidationError("Entry not found") - if entry.state is not ConfigEntryState.LOADED: - raise ServiceValidationError("Entry not loaded") - ``` -- **Exception Handling**: Raise appropriate exceptions: - ```python - # For invalid input - if end_date < start_date: - raise ServiceValidationError("End date must be after start date") - - # For service errors - try: - await client.set_schedule(start_date, end_date) - except MyConnectionError as err: - raise HomeAssistantError("Could not connect to the schedule") from err - ``` - -### Service Registration Patterns -- **Entity Services**: Register on platform setup - ```python - platform.async_register_entity_service( - "my_entity_service", - {vol.Required("parameter"): cv.string}, - "handle_service_method" - ) - ``` -- **Service Schema**: Always validate input - ```python - SERVICE_SCHEMA = vol.Schema({ - vol.Required("entity_id"): cv.entity_ids, - vol.Required("parameter"): cv.string, - vol.Optional("timeout", default=30): cv.positive_int, - }) - ``` -- **Services File**: Create `services.yaml` with descriptions and field definitions - -### Polling -- Use update coordinator pattern when possible -- **Polling intervals are NOT user-configurable**: Never add scan_interval, update_interval, or polling frequency options to config flows or config entries -- **Integration determines intervals**: Set `update_interval` programmatically based on integration logic, not user input -- **Minimum Intervals**: - - Local network: 5 seconds - - Cloud services: 60 seconds -- **Parallel Updates**: Specify number of concurrent updates: - ```python - PARALLEL_UPDATES = 1 # Serialize updates to prevent overwhelming device - # OR - PARALLEL_UPDATES = 0 # Unlimited (for coordinator-based or read-only) - ``` - -## Entity Development - -### Unique IDs -- **Required**: Every entity must have a unique ID for registry tracking -- Must be unique per platform (not per integration) -- Don't include integration domain or platform in ID -- **Implementation**: - ```python - class MySensor(SensorEntity): - def __init__(self, device_id: str) -> None: - self._attr_unique_id = f"{device_id}_temperature" - ``` - -**Acceptable ID Sources**: -- Device serial numbers -- MAC addresses (formatted using `format_mac` from device registry) -- Physical identifiers (printed/EEPROM) -- Config entry ID as last resort: `f"{entry.entry_id}-battery"` - -**Never Use**: -- IP addresses, hostnames, URLs -- Device names -- Email addresses, usernames - -### Entity Descriptions -- **Lambda/Anonymous Functions**: Often used in EntityDescription for value transformation -- **Multiline Lambdas**: When lambdas exceed line length, wrap in parentheses for readability -- **Bad pattern**: - ```python - SensorEntityDescription( - key="temperature", - name="Temperature", - value_fn=lambda data: round(data["temp_value"] * 1.8 + 32, 1) if data.get("temp_value") is not None else None, # ❌ Too long - ) - ``` -- **Good pattern**: - ```python - SensorEntityDescription( - key="temperature", - name="Temperature", - value_fn=lambda data: ( # ✅ Parenthesis on same line as lambda - round(data["temp_value"] * 1.8 + 32, 1) - if data.get("temp_value") is not None - else None - ), - ) - ``` - -### Entity Naming -- **Use has_entity_name**: Set `_attr_has_entity_name = True` -- **For specific fields**: - ```python - class MySensor(SensorEntity): - _attr_has_entity_name = True - def __init__(self, device: Device, field: str) -> None: - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.id)}, - name=device.name, - ) - self._attr_name = field # e.g., "temperature", "humidity" - ``` -- **For device itself**: Set `_attr_name = None` - -### Event Lifecycle Management -- **Subscribe in `async_added_to_hass`**: - ```python - async def async_added_to_hass(self) -> None: - """Subscribe to events.""" - self.async_on_remove( - self.client.events.subscribe("my_event", self._handle_event) - ) - ``` -- **Unsubscribe in `async_will_remove_from_hass`** if not using `async_on_remove` -- Never subscribe in `__init__` or other methods - -### State Handling -- Unknown values: Use `None` (not "unknown" or "unavailable") -- Availability: Implement `available()` property instead of using "unavailable" state - -### Entity Availability -- **Mark Unavailable**: When data cannot be fetched from device/service -- **Coordinator Pattern**: - ```python - @property - def available(self) -> bool: - """Return if entity is available.""" - return super().available and self.identifier in self.coordinator.data - ``` -- **Direct Update Pattern**: - ```python - async def async_update(self) -> None: - """Update entity.""" - try: - data = await self.client.get_data() - except MyException: - self._attr_available = False - else: - self._attr_available = True - self._attr_native_value = data.value - ``` - -### Extra State Attributes -- All attribute keys must always be present -- Unknown values: Use `None` -- Provide descriptive attributes - -## Device Management - -### Device Registry -- **Create Devices**: Group related entities under devices -- **Device Info**: Provide comprehensive metadata: - ```python - _attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, device.mac)}, - identifiers={(DOMAIN, device.id)}, - name=device.name, - manufacturer="My Company", - model="My Sensor", - sw_version=device.version, - ) - ``` -- For services: Add `entry_type=DeviceEntryType.SERVICE` - -### Dynamic Device Addition -- **Auto-detect New Devices**: After initial setup -- **Implementation Pattern**: - ```python - def _check_device() -> None: - current_devices = set(coordinator.data) - new_devices = current_devices - known_devices - if new_devices: - known_devices.update(new_devices) - async_add_entities([MySensor(coordinator, device_id) for device_id in new_devices]) - - entry.async_on_unload(coordinator.async_add_listener(_check_device)) - ``` - -### Stale Device Removal -- **Auto-remove**: When devices disappear from hub/account -- **Device Registry Update**: - ```python - device_registry.async_update_device( - device_id=device.id, - remove_config_entry_id=self.config_entry.entry_id, - ) - ``` -- **Manual Deletion**: Implement `async_remove_config_entry_device` when needed - -### Entity Categories -- **Required**: Assign appropriate category to entities -- **Implementation**: Set `_attr_entity_category` - ```python - class MySensor(SensorEntity): - _attr_entity_category = EntityCategory.DIAGNOSTIC - ``` -- Categories include: `DIAGNOSTIC` for system/technical information - -### Device Classes -- **Use When Available**: Set appropriate device class for entity type - ```python - class MyTemperatureSensor(SensorEntity): - _attr_device_class = SensorDeviceClass.TEMPERATURE - ``` -- Provides context for: unit conversion, voice control, UI representation - -### Disabled by Default -- **Disable Noisy/Less Popular Entities**: Reduce resource usage - ```python - class MySignalStrengthSensor(SensorEntity): - _attr_entity_registry_enabled_default = False - ``` -- Target: frequently changing states, technical diagnostics - -### Entity Translations -- **Required with has_entity_name**: Support international users -- **Implementation**: - ```python - class MySensor(SensorEntity): - _attr_has_entity_name = True - _attr_translation_key = "phase_voltage" - ``` -- Create `strings.json` with translations: - ```json - { - "entity": { - "sensor": { - "phase_voltage": { - "name": "Phase voltage" - } - } - } - } - ``` - -### Exception Translations (Gold) -- **Translatable Errors**: Use translation keys for user-facing exceptions -- **Implementation**: - ```python - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="end_date_before_start_date", - ) - ``` -- Add to `strings.json`: - ```json - { - "exceptions": { - "end_date_before_start_date": { - "message": "The end date cannot be before the start date." - } - } - } - ``` - -### Icon Translations (Gold) -- **Dynamic Icons**: Support state and range-based icon selection -- **State-based Icons**: - ```json - { - "entity": { - "sensor": { - "tree_pollen": { - "default": "mdi:tree", - "state": { - "high": "mdi:tree-outline" - } - } - } - } - } - ``` -- **Range-based Icons** (for numeric values): - ```json - { - "entity": { - "sensor": { - "battery_level": { - "default": "mdi:battery-unknown", - "range": { - "0": "mdi:battery-outline", - "90": "mdi:battery-90", - "100": "mdi:battery" - } - } - } - } - } - ``` - -## Testing Requirements - -- **Location**: `tests/components/{domain}/` -- **Coverage Requirement**: Above 95% test coverage for all modules -- **Best Practices**: - - Use pytest fixtures from `tests.common` - - Mock all external dependencies - - Use snapshots for complex data structures - - Follow existing test patterns - -### Config Flow Testing -- **100% Coverage Required**: All config flow paths must be tested -- **Patch Boundaries**: Only patch library or client methods when testing config flows. Do not patch methods defined in `config_flow.py`; exercise the flow logic end-to-end. -- **Test Scenarios**: - - All flow initiation methods (user, discovery, import) - - Successful configuration paths - - Error recovery scenarios - - Prevention of duplicate entries - - Flow completion after errors - - Reauthentication/reconfigure flows - -### Testing -- **Integration-specific tests** (recommended): - ```bash - pytest ./tests/components/ \ - --cov=homeassistant.components. \ - --cov-report term-missing \ - --durations-min=1 \ - --durations=0 \ - --numprocesses=auto - ``` - -### Testing Best Practices -- **Never access `hass.data` directly** - Use fixtures and proper integration setup instead -- **Use snapshot testing** - For verifying entity states and attributes -- **Test through integration setup** - Don't test entities in isolation -- **Mock external APIs** - Use fixtures with realistic JSON data -- **Verify registries** - Ensure entities are properly registered with devices - -### Config Flow Testing Template -```python -async def test_user_flow_success(hass, mock_api): - """Test successful user flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "user" - - # Test form submission - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TEST_USER_INPUT - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "My Device" - assert result["data"] == TEST_USER_INPUT - -async def test_flow_connection_error(hass, mock_api_error): - """Test connection error handling.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=TEST_USER_INPUT - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "cannot_connect"} -``` - -### Entity Testing Patterns -```python -@pytest.fixture -def platforms() -> list[Platform]: - """Overridden fixture to specify platforms to test.""" - return [Platform.SENSOR] # Or another specific platform as needed. - -@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") -async def test_entities( - hass: HomeAssistant, - snapshot: SnapshotAssertion, - entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, -) -> None: - """Test the sensor entities.""" - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) - - # Ensure entities are correctly assigned to device - device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, "device_unique_id")} - ) - assert device_entry - entity_entries = er.async_entries_for_config_entry( - entity_registry, mock_config_entry.entry_id - ) - for entity_entry in entity_entries: - assert entity_entry.device_id == device_entry.id -``` - -### Mock Patterns -```python -# Modern integration fixture setup -@pytest.fixture -def mock_config_entry() -> MockConfigEntry: - """Return the default mocked config entry.""" - return MockConfigEntry( - title="My Integration", - domain=DOMAIN, - data={CONF_HOST: "127.0.0.1", CONF_API_KEY: "test_key"}, - unique_id="device_unique_id", - ) - -@pytest.fixture -def mock_device_api() -> Generator[MagicMock]: - """Return a mocked device API.""" - with patch("homeassistant.components.my_integration.MyDeviceAPI", autospec=True) as api_mock: - api = api_mock.return_value - api.get_data.return_value = MyDeviceData.from_json( - load_fixture("device_data.json", DOMAIN) - ) - yield api - -@pytest.fixture -def platforms() -> list[Platform]: - """Fixture to specify platforms to test.""" - return PLATFORMS - -@pytest.fixture -async def init_integration( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_device_api: MagicMock, - platforms: list[Platform], -) -> MockConfigEntry: - """Set up the integration for testing.""" - mock_config_entry.add_to_hass(hass) - - with patch("homeassistant.components.my_integration.PLATFORMS", platforms): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - return mock_config_entry -``` - -## Debugging & Troubleshooting - -### Common Issues & Solutions -- **Integration won't load**: Check `manifest.json` syntax and required fields -- **Entities not appearing**: Verify `unique_id` and `has_entity_name` implementation -- **Config flow errors**: Check `strings.json` entries and error handling -- **Discovery not working**: Verify manifest discovery configuration and callbacks -- **Tests failing**: Check mock setup and async context - -### Debug Logging Setup -```python -# Enable debug logging in tests -caplog.set_level(logging.DEBUG, logger="my_integration") - -# In integration code - use proper logging -_LOGGER = logging.getLogger(__name__) -_LOGGER.debug("Processing data: %s", data) # Use lazy logging -``` - -### Validation Commands -```bash -# Check specific integration -python -m script.hassfest --integration-path homeassistant/components/my_integration - -# Validate quality scale -# Check quality_scale.yaml against current rules - -# Run integration tests with coverage -pytest ./tests/components/my_integration \ - --cov=homeassistant.components.my_integration \ - --cov-report term-missing -``` diff --git a/.claude/skills/integrations/platform-diagnostics.md b/.claude/skills/integrations/platform-diagnostics.md deleted file mode 100644 index 2d01cd08a62..00000000000 --- a/.claude/skills/integrations/platform-diagnostics.md +++ /dev/null @@ -1,19 +0,0 @@ -# Integration Diagnostics - -Platform exists as `homeassistant/components//diagnostics.py`. - -- **Required**: Implement diagnostic data collection -- **Implementation**: - ```python - TO_REDACT = [CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE] - - async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: MyConfigEntry - ) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - return { - "entry_data": async_redact_data(entry.data, TO_REDACT), - "data": entry.runtime_data.data, - } - ``` -- **Security**: Never expose passwords, tokens, or sensitive coordinates diff --git a/.claude/skills/integrations/platform-repairs.md b/.claude/skills/integrations/platform-repairs.md deleted file mode 100644 index 08d631dd5f0..00000000000 --- a/.claude/skills/integrations/platform-repairs.md +++ /dev/null @@ -1,55 +0,0 @@ -# Repairs platform - -Platform exists as `homeassistant/components//repairs.py`. - -- **Actionable Issues Required**: All repair issues must be actionable for end users -- **Issue Content Requirements**: - - Clearly explain what is happening - - Provide specific steps users need to take to resolve the issue - - Use friendly, helpful language - - Include relevant context (device names, error details, etc.) -- **Implementation**: - ```python - ir.async_create_issue( - hass, - DOMAIN, - "outdated_version", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.ERROR, - translation_key="outdated_version", - ) - ``` -- **Translation Strings Requirements**: Must contain user-actionable text in `strings.json`: - ```json - { - "issues": { - "outdated_version": { - "title": "Device firmware is outdated", - "description": "Your device firmware version {current_version} is below the minimum required version {min_version}. To fix this issue: 1) Open the manufacturer's mobile app, 2) Navigate to device settings, 3) Select 'Update Firmware', 4) Wait for the update to complete, then 5) Restart Home Assistant." - } - } - } - ``` -- **String Content Must Include**: - - What the problem is - - Why it matters - - Exact steps to resolve (numbered list when multiple steps) - - What to expect after following the steps -- **Avoid Vague Instructions**: Don't just say "update firmware" - provide specific steps -- **Severity Guidelines**: - - `CRITICAL`: Reserved for extreme scenarios only - - `ERROR`: Requires immediate user attention - - `WARNING`: Indicates future potential breakage -- **Additional Attributes**: - ```python - ir.async_create_issue( - hass, DOMAIN, "issue_id", - breaks_in_ha_version="2024.1.0", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.ERROR, - translation_key="issue_description", - ) - ``` -- Only create issues for problems users can potentially resolve 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/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3bc651eb2f2..44393a78a3e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,16 +5,16 @@ # 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 This repository contains the core of Home Assistant, a Python 3 based home automation application. -## Code Review Guidelines +## Git Commit Guidelines -**Git commit practices during review:** -- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review +- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review ## Development Commands @@ -22,18 +22,20 @@ This repository contains the core of Home Assistant, a Python 3 based home autom ## 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`. +- When writing or modifying tests, ensure all test function parameters have type annotations. +- Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`. +- 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. ## 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/instructions/integrations.instructions.md b/.github/instructions/integrations.instructions.md new file mode 100644 index 00000000000..4cbe4e0f18b --- /dev/null +++ b/.github/instructions/integrations.instructions.md @@ -0,0 +1,50 @@ +--- +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()`. + +## 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..2f79e54020f --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,217 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended"], + + "enabledManagers": [ + "pep621", + "pip_requirements", + "pre-commit", + "custom.regex", + "homeassistant-manifest" + ], + + "pre-commit": { + "enabled": true + }, + + "pip_requirements": { + "managerFilePatterns": [ + "/(^|/)requirements[\\w_-]*\\.txt$/", + "/(^|/)homeassistant/package_constraints\\.txt$/" + ] + }, + + "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" + } + ], + + "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", + "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" + ], + "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": "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" + } + ] +} diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 4a9b7033842..6aaef512a75 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.04.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@master # 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@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . # So action will not pull the repository again file: ./script/hassfest/docker/Dockerfile diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6aa251db25f..53f81800a42 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,9 +38,8 @@ on: env: CACHE_VERSION: 3 - UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2026.5" + HA_SHORT_VERSION: "2026.6" ADDITIONAL_PYTHON_VERSIONS: "[]" # 10.3 is the oldest supported version # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) @@ -50,9 +49,11 @@ env: # - 10.10.3 is the latest (as of 6 Feb 2023) # 10.11 is the latest long-term-support # - 10.11.2 is the version currently shipped with Synology (as of 11 Oct 2023) + # 11.4 is an LTS with support until May 2029 + # - 11.4.9 is used in Alpine 3.23 (used in latest HA base images as of 11 Apr 2026) # mysql 8.0.32 does not always behave the same as MariaDB # and some queries that work on MariaDB do not work on MySQL - MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mariadb:10.11.2','mysql:8.0.32']" + MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mariadb:10.11.2','mariadb:11.4.9','mysql:8.0.32']" # 12 is the oldest supported version # - 12.14 is the latest (as of 9 Feb 2023) # 15 is the latest version @@ -280,7 +281,7 @@ jobs: echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json" echo "::add-matcher::.github/workflows/matchers/codespell.json" - name: Run prek - uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0 + uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3 env: PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor RUFF_OUTPUT_FORMAT: github @@ -301,7 +302,7 @@ jobs: with: persist-credentials: false - name: Run zizmor - uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0 + uses: j178/prek-action@6ad80277337ad479fe43bd70701c3f7f8aa74db3 # v2.0.3 with: extra-args: --all-files zizmor @@ -356,35 +357,36 @@ jobs: with: python-version: ${{ matrix.python-version }} check-latest: true - - name: Generate partial uv restore key - id: generate-uv-key - run: | - uv_version=$(cat requirements.txt | grep uv | cut -d '=' -f 3) - echo "version=${uv_version}" >> $GITHUB_OUTPUT - echo "key=uv-${UV_CACHE_VERSION}-${uv_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv key: >- ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} + - name: Generate partial uv restore key + if: steps.cache-venv.outputs.cache-hit != 'true' + id: generate-uv-key + env: + RUNNER_OS: ${{ runner.os }} + RUNNER_ARCH: ${{ runner.arch }} + PYTHON_VERSION: ${{ steps.python.outputs.python-version }} + HASH_FILES: ${{ hashFiles('requirements.txt', 'requirements_all.txt', 'requirements_test.txt', 'homeassistant/package_constraints.txt') }} + run: | + partial_key="${RUNNER_OS}-${RUNNER_ARCH}-${PYTHON_VERSION}-uv-" + echo "partial_key=${partial_key}" >> $GITHUB_OUTPUT + echo "full_key=${partial_key}${HASH_FILES}" >> $GITHUB_OUTPUT - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ env.UV_CACHE_DIR }} - key: >- - ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ - steps.generate-uv-key.outputs.key }} - restore-keys: | - ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-uv-${{ - env.UV_CACHE_VERSION }}-${{ steps.generate-uv-key.outputs.version }}-${{ - env.HA_SHORT_VERSION }}- + key: ${{ steps.generate-uv-key.outputs.full_key }} + restore-keys: ${{ steps.generate-uv-key.outputs.partial_key }} - name: Check if apt cache exists id: cache-apt-check - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }} path: | @@ -396,6 +398,7 @@ jobs: if: | steps.cache-venv.outputs.cache-hit != 'true' || steps.cache-apt-check.outputs.cache-hit != 'true' + id: install-os-deps timeout-minutes: 10 env: APT_CACHE_HIT: ${{ steps.cache-apt-check.outputs.cache-hit }} @@ -429,8 +432,11 @@ jobs: sudo chmod -R 755 ${APT_CACHE_BASE} fi - name: Save apt cache - if: steps.cache-apt-check.outputs.cache-hit != 'true' - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + if: | + always() + && steps.cache-apt-check.outputs.cache-hit != 'true' + && steps.install-os-deps.outcome == 'success' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.APT_CACHE_DIR }} @@ -439,6 +445,7 @@ jobs: ${{ runner.os }}-${{ runner.arch }}-${{ needs.info.outputs.apt_cache_key }} - name: Create Python virtual environment if: steps.cache-venv.outputs.cache-hit != 'true' + id: create-venv run: | python -m venv venv . venv/bin/activate @@ -446,8 +453,7 @@ jobs: pip install "$(grep '^uv' < requirements.txt)" uv pip install -U "pip>=25.2" uv pip install -r requirements.txt - python -m script.gen_requirements_all ci - uv pip install -r requirements_all_pytest.txt -r requirements_test.txt + uv pip install -r requirements_all.txt -r requirements_test.txt uv pip install -e . --config-settings editable_mode=compat - name: Dump pip freeze run: | @@ -456,19 +462,36 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt overwrite: true - name: Remove pip_freeze run: rm pip_freeze.txt - - name: Remove generated requirements_all - if: steps.cache-venv.outputs.cache-hit != 'true' - run: rm requirements_all_pytest.txt requirements_all_wheels_*.txt - name: Check dirty run: | ./script/check_dirty + - name: Prune uv cache + if: steps.cache-venv.outputs.cache-hit != 'true' + id: prune-uv-cache + run: | + . venv/bin/activate + uv cache prune --ci + - name: Save uv wheel cache + if: steps.cache-venv.outputs.cache-hit != 'true' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ${{ env.UV_CACHE_DIR }} + key: ${{ steps.generate-uv-key.outputs.full_key }} + - name: Save base Python virtual environment + if: always() && steps.create-venv.outcome == 'success' + uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: venv + key: >- + ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ + needs.info.outputs.python_cache_key }} hassfest: name: Check hassfest @@ -484,7 +507,7 @@ jobs: && github.event.inputs.audit-licenses-only != 'true' steps: - name: Restore apt cache - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.APT_CACHE_DIR }} @@ -515,7 +538,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -552,7 +575,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -643,7 +666,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -657,7 +680,7 @@ jobs: . venv/bin/activate python -m script.licenses extract --output-file=licenses-${PYTHON_VERSION}.json - name: Upload licenses - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: licenses-${{ github.run_number }}-${{ matrix.python-version }} path: licenses-${{ matrix.python-version }}.json @@ -694,7 +717,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -747,7 +770,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -804,7 +827,7 @@ jobs: echo "key=mypy-${MYPY_CACHE_VERSION}-${mypy_version}-${HA_SHORT_VERSION}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -812,7 +835,7 @@ jobs: ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .mypy_cache key: >- @@ -854,7 +877,7 @@ jobs: - base steps: - name: Restore apt cache - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.APT_CACHE_DIR }} @@ -887,7 +910,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -901,7 +924,7 @@ jobs: . venv/bin/activate python -m script.split_tests ${TEST_GROUP_COUNT} tests - name: Upload pytest_buckets - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pytest_buckets path: pytest_buckets.txt @@ -930,7 +953,7 @@ jobs: group: ${{ fromJson(needs.info.outputs.test_groups) }} steps: - name: Restore apt cache - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.APT_CACHE_DIR }} @@ -964,7 +987,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -1020,14 +1043,14 @@ jobs: 2>&1 | tee pytest-${PYTHON_VERSION}-${TEST_GROUP}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-full.conclusion == 'failure' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1040,7 +1063,7 @@ jobs: mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml @@ -1062,7 +1085,9 @@ jobs: - 3306:3306 env: MYSQL_ROOT_PASSWORD: password - options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3 + options: >- + --health-cmd="if command -v mariadb-admin >/dev/null; then mariadb-admin ping -uroot -ppassword; else mysqladmin ping -uroot -ppassword; fi" + --health-interval=5s --health-timeout=2s --health-retries=3 needs: - info - base @@ -1080,7 +1105,7 @@ jobs: mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }} steps: - name: Restore apt cache - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.APT_CACHE_DIR }} @@ -1115,7 +1140,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -1177,7 +1202,7 @@ jobs: 2>&1 | tee pytest-${PYTHON_VERSION}-${mariadb}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1185,7 +1210,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1199,7 +1224,7 @@ jobs: mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-results-mariadb-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.mariadb }} @@ -1238,7 +1263,7 @@ jobs: postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }} steps: - name: Restore apt cache - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.APT_CACHE_DIR }} @@ -1275,7 +1300,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -1338,7 +1363,7 @@ jobs: 2>&1 | tee pytest-${PYTHON_VERSION}-${postgresql}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1346,7 +1371,7 @@ jobs: overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1360,7 +1385,7 @@ jobs: mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-results-postgres-${{ matrix.python-version }}-${{ steps.pytest-partial.outputs.postgresql }} @@ -1392,7 +1417,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: fail_ci_if_error: true flags: full-suite @@ -1421,7 +1446,7 @@ jobs: group: ${{ fromJson(needs.info.outputs.test_groups) }} steps: - name: Restore apt cache - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.APT_CACHE_DIR }} @@ -1455,7 +1480,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -1514,14 +1539,14 @@ jobs: 2>&1 | tee pytest-${PYTHON_VERSION}-${TEST_GROUP}.txt - name: Upload pytest output if: success() || failure() && steps.pytest-partial.conclusion == 'failure' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} path: pytest-*.txt overwrite: true - name: Upload coverage artifact if: needs.info.outputs.skip_coverage != 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-${{ matrix.python-version }}-${{ matrix.group }} path: coverage.xml @@ -1534,7 +1559,7 @@ jobs: mv "junit.xml-tmp" "junit.xml" - name: Upload test results artifact if: needs.info.outputs.skip_coverage != 'true' && !cancelled() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }} path: junit.xml @@ -1563,7 +1588,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} # zizmor: ignore[secrets-outside-env] @@ -1591,7 +1616,7 @@ jobs: with: pattern: test-results-* - name: Upload test results to Codecov - uses: codecov/codecov-action@1af58845a975a7985b0beb0cbe6fbbb71a41dbad # v5.5.3 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: report_type: test_results fail_ci_if_error: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b0d1025642e..53aeea74705 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,11 +28,11 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 with: category: "/language:python" diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index 8270a2040a9..6fb8fc52dee 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Check if integration label was added and extract details id: extract - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | // Debug: Log the event payload @@ -118,7 +118,7 @@ jobs: - name: Fetch similar issues id: fetch_similar if: steps.extract.outputs.should_continue == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: INTEGRATION_LABELS: ${{ steps.extract.outputs.integration_labels }} CURRENT_NUMBER: ${{ steps.extract.outputs.current_number }} @@ -285,7 +285,7 @@ jobs: - name: Post duplicate detection results id: post_results if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: AI_RESPONSE: ${{ steps.ai_detection.outputs.response }} SIMILAR_ISSUES: ${{ steps.fetch_similar.outputs.similar_issues }} diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index cab2b728b32..01bf999cf2f 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Check issue language id: detect_language - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: ISSUE_NUMBER: ${{ github.event.issue.number }} ISSUE_TITLE: ${{ github.event.issue.title }} @@ -95,7 +95,7 @@ jobs: - name: Process non-English issues if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: AI_RESPONSE: ${{ steps.ai_language_detection.outputs.response }} ISSUE_NUMBER: ${{ steps.detect_language.outputs.issue_number }} diff --git a/.github/workflows/restrict-task-creation.yml b/.github/workflows/restrict-task-creation.yml index 96828d06931..6c10806333a 100644 --- a/.github/workflows/restrict-task-creation.yml +++ b/.github/workflows/restrict-task-creation.yml @@ -22,7 +22,7 @@ jobs: || github.event.issue.type.name == 'Opportunity' steps: - name: Add no-stale label - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | await github.rest.issues.addLabels({ @@ -42,7 +42,7 @@ jobs: if: github.event.issue.type.name == 'Task' steps: - name: Check if user is authorized - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const issueAuthor = context.payload.issue.user.login; diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 86ead98ad59..db0472c6834 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -74,7 +74,7 @@ jobs: ) > .env_file - name: Upload env_file - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: env_file path: ./.env_file @@ -82,7 +82,7 @@ jobs: overwrite: true - name: Upload requirements_diff - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: requirements_diff path: ./requirements_diff.txt @@ -94,7 +94,7 @@ jobs: python -m script.gen_requirements_all ci - name: Upload requirements_all_wheels - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: requirements_all_wheels path: ./requirements_all_wheels_*.txt diff --git a/.gitignore b/.gitignore index 77b5cc6933b..9d8cbaf15e0 100644 --- a/.gitignore +++ b/.gitignore @@ -142,5 +142,6 @@ pytest_buckets.txt # AI tooling .claude/settings.local.json +.claude/worktrees/ .serena/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 018b971cbe2..6be30da9f60 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.1 + rev: v0.15.12 hooks: - id: ruff-check args: @@ -8,7 +8,7 @@ repos: - id: ruff-format files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$ - repo: https://github.com/codespell-project/codespell - rev: v2.4.1 + rev: v2.4.2 hooks: - id: codespell args: @@ -18,7 +18,7 @@ repos: exclude_types: [csv, json, html] exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ - repo: https://github.com/zizmorcore/zizmor-pre-commit - rev: v1.23.1 + rev: v1.24.1 hooks: - id: zizmor args: @@ -36,7 +36,7 @@ repos: - --branch=master - --branch=rc - repo: https://github.com/adrienverge/yamllint.git - rev: v1.37.1 + rev: v1.38.0 hooks: - id: yamllint - repo: https://github.com/rbubley/mirrors-prettier @@ -87,6 +87,13 @@ repos: language: script types: [text] files: ^(homeassistant/.+/manifest\.json|homeassistant/brands/.+\.json|pyproject\.toml|\.pre-commit-config\.yaml|script/gen_requirements_all\.py)$ + - id: gen_copilot_instructions + name: gen_copilot_instructions + entry: script/run-in-env.sh python3 -m script.gen_copilot_instructions + pass_filenames: false + language: script + types: [text] + files: ^(AGENTS\.md|\.claude/skills/(?!github-pr-reviewer/).+/SKILL\.md|\.github/copilot-instructions\.md|script/gen_copilot_instructions\.py)$ - id: hassfest name: hassfest entry: script/run-in-env.sh python3 -m script.hassfest diff --git a/.python-version b/.python-version index 95ed564f82b..0104088a93f 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.14.2 +3.14.4 diff --git a/.strict-typing b/.strict-typing index 5e154925661..65d63a74258 100644 --- a/.strict-typing +++ b/.strict-typing @@ -46,6 +46,7 @@ homeassistant.components.accuweather.* homeassistant.components.acer_projector.* homeassistant.components.acmeda.* homeassistant.components.actiontec.* +homeassistant.components.actron_air.* homeassistant.components.adax.* homeassistant.components.adguard.* homeassistant.components.aftership.* @@ -178,6 +179,7 @@ homeassistant.components.dropbox.* homeassistant.components.droplet.* homeassistant.components.dsmr.* homeassistant.components.duckdns.* +homeassistant.components.duco.* homeassistant.components.dunehd.* homeassistant.components.duotecno.* homeassistant.components.easyenergy.* @@ -222,6 +224,7 @@ homeassistant.components.fronius.* homeassistant.components.frontend.* homeassistant.components.fujitsu_fglair.* homeassistant.components.fully_kiosk.* +homeassistant.components.fumis.* homeassistant.components.fyta.* homeassistant.components.generic_hygrostat.* homeassistant.components.generic_thermostat.* @@ -332,6 +335,7 @@ homeassistant.components.letpot.* homeassistant.components.lg_infrared.* homeassistant.components.libre_hardware_monitor.* homeassistant.components.lidarr.* +homeassistant.components.liebherr.* homeassistant.components.lifx.* homeassistant.components.light.* homeassistant.components.linkplay.* @@ -438,6 +442,7 @@ homeassistant.components.private_ble_device.* homeassistant.components.prometheus.* homeassistant.components.proximity.* homeassistant.components.prusalink.* +homeassistant.components.ptdevices.* homeassistant.components.pure_energie.* homeassistant.components.purpleair.* homeassistant.components.pushbullet.* @@ -551,6 +556,7 @@ homeassistant.components.tcp.* homeassistant.components.technove.* homeassistant.components.tedee.* homeassistant.components.telegram_bot.* +homeassistant.components.teleinfo.* homeassistant.components.teslemetry.* homeassistant.components.text.* homeassistant.components.thethingsnetwork.* @@ -594,6 +600,7 @@ homeassistant.components.vallox.* homeassistant.components.valve.* homeassistant.components.velbus.* homeassistant.components.velux.* +homeassistant.components.victron_gx.* homeassistant.components.vivotek.* homeassistant.components.vlc_telnet.* homeassistant.components.vodafone_station.* diff --git a/AGENTS.md b/AGENTS.md index 888d93ec07e..bfcddefa342 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,10 +2,9 @@ This repository contains the core of Home Assistant, a Python 3 based home automation application. -## Code Review Guidelines +## Git Commit Guidelines -**Git commit practices during review:** -- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review +- **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened** - Reviewers need to follow the commit history, as well as see what changed since their last review ## Development Commands @@ -13,13 +12,20 @@ This repository contains the core of Home Assistant, a Python 3 based home autom ## 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`. +- When writing or modifying tests, ensure all test function parameters have type annotations. +- Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`. +- 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. ## 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. +- 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/CODEOWNERS b/CODEOWNERS index 2d5ee30b071..2ba6cac4881 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -37,6 +37,13 @@ build.json @home-assistant/supervisor # Other code /homeassistant/scripts/check_config.py @kellerza +# Agent Configurations +AGENTS.md @home-assistant/core +CLAUDE.md @home-assistant/core +/.agent/ @home-assistant/core +/.claude/ @home-assistant/core +/.gemini/ @home-assistant/core + # Integrations /homeassistant/components/abode/ @shred86 /tests/components/abode/ @shred86 @@ -222,8 +229,8 @@ build.json @home-assistant/supervisor /homeassistant/components/binary_sensor/ @home-assistant/core /tests/components/binary_sensor/ @home-assistant/core /homeassistant/components/bizkaibus/ @UgaitzEtxebarria -/homeassistant/components/blebox/ @bbx-a @swistakm -/tests/components/blebox/ @bbx-a @swistakm +/homeassistant/components/blebox/ @bbx-a @swistakm @bkobus-bbx +/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx /homeassistant/components/blink/ @fronzbot /tests/components/blink/ @fronzbot /homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23 @@ -355,6 +362,8 @@ build.json @home-assistant/supervisor /tests/components/deluge/ @tkdrob /homeassistant/components/demo/ @home-assistant/core /tests/components/demo/ @home-assistant/core +/homeassistant/components/denon_rs232/ @balloob +/tests/components/denon_rs232/ @balloob /homeassistant/components/denonavr/ @ol-iver @starkillerOG /tests/components/denonavr/ @ol-iver @starkillerOG /homeassistant/components/derivative/ @afaucogney @karwosts @@ -391,6 +400,8 @@ build.json @home-assistant/supervisor /tests/components/dnsip/ @gjohansson-ST /homeassistant/components/door/ @home-assistant/core /tests/components/door/ @home-assistant/core +/homeassistant/components/doorbell/ @home-assistant/core +/tests/components/doorbell/ @home-assistant/core /homeassistant/components/doorbird/ @oblogic7 @bdraco @flacjacket /tests/components/doorbird/ @oblogic7 @bdraco @flacjacket /homeassistant/components/dormakaba_dkey/ @emontnemery @@ -411,6 +422,8 @@ build.json @home-assistant/supervisor /tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna /homeassistant/components/duckdns/ @tr4nt0r /tests/components/duckdns/ @tr4nt0r +/homeassistant/components/duco/ @ronaldvdmeer +/tests/components/duco/ @ronaldvdmeer /homeassistant/components/duotecno/ @cereal2nd /tests/components/duotecno/ @cereal2nd /homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @@ -419,6 +432,8 @@ build.json @home-assistant/supervisor /tests/components/dynalite/ @ziv1234 /homeassistant/components/eafm/ @Jc2k /tests/components/eafm/ @Jc2k +/homeassistant/components/earn_e_p1/ @Miggets7 +/tests/components/earn_e_p1/ @Miggets7 /homeassistant/components/easyenergy/ @klaasnicolaas /tests/components/easyenergy/ @klaasnicolaas /homeassistant/components/ecoforest/ @pjanuario @@ -480,8 +495,8 @@ build.json @home-assistant/supervisor /homeassistant/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/ephember/ @ttroy50 @roberty99 -/homeassistant/components/epic_games_store/ @hacf-fr @Quentame -/tests/components/epic_games_store/ @hacf-fr @Quentame +/homeassistant/components/epic_games_store/ @Quentame +/tests/components/epic_games_store/ @Quentame /homeassistant/components/epion/ @lhgravendeel /tests/components/epion/ @lhgravendeel /homeassistant/components/epson/ @pszafer @@ -496,6 +511,8 @@ build.json @home-assistant/supervisor /tests/components/essent/ @jaapp /homeassistant/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99 +/homeassistant/components/eurotronic_cometblue/ @rikroe +/tests/components/eurotronic_cometblue/ @rikroe /homeassistant/components/event/ @home-assistant/core /tests/components/event/ @home-assistant/core /homeassistant/components/evohome/ @zxdavb @@ -555,8 +572,8 @@ build.json @home-assistant/supervisor /homeassistant/components/fortios/ @kimfrellsen /homeassistant/components/foscam/ @Foscam-wangzhengyu /tests/components/foscam/ @Foscam-wangzhengyu -/homeassistant/components/freebox/ @hacf-fr @Quentame -/tests/components/freebox/ @hacf-fr @Quentame +/homeassistant/components/freebox/ @hacf-fr/reviewers @Quentame +/tests/components/freebox/ @hacf-fr/reviewers @Quentame /homeassistant/components/freedompro/ @stefano055415 /tests/components/freedompro/ @stefano055415 /homeassistant/components/freshr/ @SierraNL @@ -577,6 +594,8 @@ build.json @home-assistant/supervisor /tests/components/fujitsu_fglair/ @crevetor /homeassistant/components/fully_kiosk/ @cgarwood /tests/components/fully_kiosk/ @cgarwood +/homeassistant/components/fumis/ @frenck +/tests/components/fumis/ @frenck /homeassistant/components/fyta/ @dontinelli /tests/components/fyta/ @dontinelli /homeassistant/components/garage_door/ @home-assistant/core @@ -739,6 +758,8 @@ build.json @home-assistant/supervisor /tests/components/homewizard/ @DCSBL /homeassistant/components/honeywell/ @rdfurman @mkmer /tests/components/honeywell/ @rdfurman @mkmer +/homeassistant/components/honeywell_string_lights/ @balloob +/tests/components/honeywell_string_lights/ @balloob /homeassistant/components/hr_energy_qube/ @MattieGit /tests/components/hr_energy_qube/ @MattieGit /homeassistant/components/html5/ @alexyao2015 @tr4nt0r @@ -830,8 +851,8 @@ build.json @home-assistant/supervisor /tests/components/input_select/ @home-assistant/core /homeassistant/components/input_text/ @home-assistant/core /tests/components/input_text/ @home-assistant/core -/homeassistant/components/insteon/ @teharris1 -/tests/components/insteon/ @teharris1 +/homeassistant/components/insteon/ @teharris1 @ssyrell +/tests/components/insteon/ @teharris1 @ssyrell /homeassistant/components/integration/ @dgomes /tests/components/integration/ @dgomes /homeassistant/components/intelliclima/ @dvdinth @@ -887,8 +908,8 @@ build.json @home-assistant/supervisor /tests/components/jewish_calendar/ @tsvi /homeassistant/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen -/homeassistant/components/jvc_projector/ @SteveEasley @msavazzi -/tests/components/jvc_projector/ @SteveEasley @msavazzi +/homeassistant/components/jvc_projector/ @SteveEasley +/tests/components/jvc_projector/ @SteveEasley /homeassistant/components/kaiterra/ @Michsior14 /homeassistant/components/kaleidescape/ @SteveEasley /tests/components/kaleidescape/ @SteveEasley @@ -901,6 +922,8 @@ build.json @home-assistant/supervisor /homeassistant/components/keyboard_remote/ @bendavid @lanrat /homeassistant/components/keymitt_ble/ @spycle /tests/components/keymitt_ble/ @spycle +/homeassistant/components/kiosker/ @Claeysson +/tests/components/kiosker/ @Claeysson /homeassistant/components/kitchen_sink/ @home-assistant/core /tests/components/kitchen_sink/ @home-assistant/core /homeassistant/components/kmtronic/ @dgomes @@ -1046,8 +1069,8 @@ build.json @home-assistant/supervisor /tests/components/met/ @danielhiversen /homeassistant/components/met_eireann/ @DylanGore /tests/components/met_eireann/ @DylanGore -/homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame -/tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame +/homeassistant/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame +/tests/components/meteo_france/ @hacf-fr/reviewers @oncleben31 @Quentame /homeassistant/components/meteo_lt/ @xE1H /tests/components/meteo_lt/ @xE1H /homeassistant/components/meteoalarm/ @rolfberkenbosch @@ -1069,6 +1092,8 @@ build.json @home-assistant/supervisor /tests/components/minecraft_server/ @elmurato @zachdeibert /homeassistant/components/minio/ @tkislan /tests/components/minio/ @tkislan +/homeassistant/components/mitsubishi_comfort/ @nikolairahimi +/tests/components/mitsubishi_comfort/ @nikolairahimi /homeassistant/components/moat/ @bdraco /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core @@ -1139,8 +1164,8 @@ build.json @home-assistant/supervisor /homeassistant/components/netatmo/ @cgtobi /tests/components/netatmo/ @cgtobi /homeassistant/components/netdata/ @fabaff -/homeassistant/components/netgear/ @hacf-fr @Quentame @starkillerOG -/tests/components/netgear/ @hacf-fr @Quentame @starkillerOG +/homeassistant/components/netgear/ @Quentame @starkillerOG +/tests/components/netgear/ @Quentame @starkillerOG /homeassistant/components/netgear_lte/ @tkdrob /tests/components/netgear_lte/ @tkdrob /homeassistant/components/network/ @home-assistant/core @@ -1180,6 +1205,8 @@ build.json @home-assistant/supervisor /tests/components/notify_events/ @matrozov @papajojo /homeassistant/components/notion/ @bachya /tests/components/notion/ @bachya +/homeassistant/components/novy_cooker_hood/ @piitaya +/tests/components/novy_cooker_hood/ @piitaya /homeassistant/components/nrgkick/ @andijakl /tests/components/nrgkick/ @andijakl /homeassistant/components/nsw_fuel_station/ @nickw444 @@ -1216,6 +1243,8 @@ build.json @home-assistant/supervisor /homeassistant/components/ollama/ @synesthesiam /tests/components/ollama/ @synesthesiam /homeassistant/components/ombi/ @larssont +/homeassistant/components/omie/ @luuuis +/tests/components/omie/ @luuuis /homeassistant/components/onboarding/ @home-assistant/core /tests/components/onboarding/ @home-assistant/core /homeassistant/components/ondilo_ico/ @JeromeHXP @@ -1232,8 +1261,10 @@ build.json @home-assistant/supervisor /tests/components/onvif/ @jterrace /homeassistant/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck -/homeassistant/components/open_router/ @joostlek -/tests/components/open_router/ @joostlek +/homeassistant/components/open_router/ @joostlek @ab3lson +/tests/components/open_router/ @joostlek @ab3lson +/homeassistant/components/openai_conversation/ @Shulyaka +/tests/components/openai_conversation/ @Shulyaka /homeassistant/components/opendisplay/ @g4bri3lDev /tests/components/opendisplay/ @g4bri3lDev /homeassistant/components/openerz/ @misialq @@ -1256,8 +1287,8 @@ build.json @home-assistant/supervisor /tests/components/openuv/ @bachya /homeassistant/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck /tests/components/openweathermap/ @fabaff @freekode @nzapponi @wittypluck -/homeassistant/components/opnsense/ @mtreinish -/tests/components/opnsense/ @mtreinish +/homeassistant/components/opnsense/ @HarlemSquirrel @Snuffy2 +/tests/components/opnsense/ @HarlemSquirrel @Snuffy2 /homeassistant/components/opower/ @tronikos /tests/components/opower/ @tronikos /homeassistant/components/oralb/ @bdraco @Lash-L @@ -1301,6 +1332,8 @@ build.json @home-assistant/supervisor /tests/components/pi_hole/ @shenxn /homeassistant/components/picnic/ @corneyl @codesalatdev /tests/components/picnic/ @corneyl @codesalatdev +/homeassistant/components/picotts/ @rooggiieerr +/tests/components/picotts/ @rooggiieerr /homeassistant/components/ping/ @jpbede /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan @@ -1347,6 +1380,8 @@ build.json @home-assistant/supervisor /tests/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 +/homeassistant/components/ptdevices/ @ParemTech-Inc @frogman85978 +/tests/components/ptdevices/ @ParemTech-Inc @frogman85978 /homeassistant/components/pterodactyl/ @elmurato /tests/components/pterodactyl/ @elmurato /homeassistant/components/pure_energie/ @klaasnicolaas @@ -1390,6 +1425,8 @@ build.json @home-assistant/supervisor /tests/components/radarr/ @tkdrob /homeassistant/components/radio_browser/ @frenck /tests/components/radio_browser/ @frenck +/homeassistant/components/radio_frequency/ @home-assistant/core +/tests/components/radio_frequency/ @home-assistant/core /homeassistant/components/radiotherm/ @vinnyfuria /tests/components/radiotherm/ @vinnyfuria /homeassistant/components/rainbird/ @konikvranik @allenporter @@ -1458,8 +1495,8 @@ build.json @home-assistant/supervisor /tests/components/roku/ @ctalkington /homeassistant/components/romy/ @xeniter /tests/components/romy/ @xeniter -/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous -/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous +/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn +/tests/components/roomba/ @pschmitt @cyr-ius @shenxn /homeassistant/components/roon/ @pavoni /tests/components/roon/ @pavoni /homeassistant/components/route_b_smart_meter/ @SeraphicRav @@ -1681,8 +1718,8 @@ build.json @home-assistant/supervisor /tests/components/syncthing/ @zhulik /homeassistant/components/syncthru/ @nielstron /tests/components/syncthru/ @nielstron -/homeassistant/components/synology_dsm/ @hacf-fr @Quentame @mib1185 -/tests/components/synology_dsm/ @hacf-fr @Quentame @mib1185 +/homeassistant/components/synology_dsm/ @Quentame @mib1185 +/tests/components/synology_dsm/ @Quentame @mib1185 /homeassistant/components/synology_srm/ @aerialls /homeassistant/components/system_bridge/ @timmo001 /tests/components/system_bridge/ @timmo001 @@ -1713,6 +1750,8 @@ build.json @home-assistant/supervisor /tests/components/tedee/ @patrickhilker @zweckj /homeassistant/components/telegram_bot/ @hanwg /tests/components/telegram_bot/ @hanwg +/homeassistant/components/teleinfo/ @esciara +/tests/components/teleinfo/ @esciara /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike /homeassistant/components/teltonika/ @karlbeecken @@ -1815,6 +1854,8 @@ build.json @home-assistant/supervisor /homeassistant/components/unifi_access/ @imhotep @RaHehl /tests/components/unifi_access/ @imhotep @RaHehl /homeassistant/components/unifi_direct/ @tofuSCHNITZEL +/homeassistant/components/unifi_discovery/ @RaHehl +/tests/components/unifi_discovery/ @RaHehl /homeassistant/components/unifiled/ @florisvdk /homeassistant/components/unifiprotect/ @RaHehl /tests/components/unifiprotect/ @RaHehl @@ -1862,10 +1903,12 @@ build.json @home-assistant/supervisor /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven /tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven -/homeassistant/components/vicare/ @CFenner -/tests/components/vicare/ @CFenner +/homeassistant/components/vicare/ @CFenner @lackas +/tests/components/vicare/ @CFenner @lackas /homeassistant/components/victron_ble/ @rajlaud /tests/components/victron_ble/ @rajlaud +/homeassistant/components/victron_gx/ @tomer-w +/tests/components/victron_gx/ @tomer-w /homeassistant/components/victron_remote_monitoring/ @AndyTempel /tests/components/victron_remote_monitoring/ @AndyTempel /homeassistant/components/vilfo/ @ManneW @@ -1952,8 +1995,8 @@ build.json @home-assistant/supervisor /tests/components/wled/ @frenck @mik-laj /homeassistant/components/wmspro/ @mback2k /tests/components/wmspro/ @mback2k -/homeassistant/components/wolflink/ @adamkrol93 @mtielen -/tests/components/wolflink/ @adamkrol93 @mtielen +/homeassistant/components/wolflink/ @adamkrol93 @EnjoyingM +/tests/components/wolflink/ @adamkrol93 @EnjoyingM /homeassistant/components/workday/ @fabaff @gjohansson-ST /tests/components/workday/ @fabaff @gjohansson-ST /homeassistant/components/worldclock/ @fabaff @@ -1964,8 +2007,8 @@ build.json @home-assistant/supervisor /tests/components/wsdot/ @ucodery /homeassistant/components/wyoming/ @synesthesiam /tests/components/wyoming/ @synesthesiam -/homeassistant/components/xbox/ @hunterjm @tr4nt0r -/tests/components/xbox/ @hunterjm @tr4nt0r +/homeassistant/components/xbox/ @tr4nt0r +/tests/components/xbox/ @tr4nt0r /homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi /tests/components/xiaomi_aqara/ @danielhiversen @syssi /homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79 diff --git a/Dockerfile b/Dockerfile index 460a738f1c6..5ce38cd1656 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769 # Automatically generated by hassfest. # # To update, run python3 -m script.hassfest -p docker @@ -19,25 +20,22 @@ ENV \ UV_SYSTEM_PYTHON=true \ UV_NO_CACHE=true +WORKDIR /usr/src + # Home Assistant S6-Overlay COPY rootfs / # Add go2rtc binary COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc +## Setup Home Assistant Core dependencies +COPY --parents requirements.txt homeassistant/package_constraints.txt homeassistant/ RUN \ # Verify go2rtc can be executed go2rtc --version \ - # Install uv - && pip3 install uv==0.11.1 - -WORKDIR /usr/src - -## Setup Home Assistant Core dependencies -COPY requirements.txt homeassistant/ -COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ -RUN \ - uv pip install \ + # Install uv at the version pinned in the requirements file + && pip3 install --no-cache-dir "uv==$(awk -F'==' '/^uv==/{print $2}' homeassistant/requirements.txt)" \ + && uv pip install \ --no-build \ -r homeassistant/requirements.txt @@ -51,7 +49,7 @@ RUN \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core -COPY . homeassistant/ +COPY --parents LICENSE* README* homeassistant/ pyproject.toml homeassistant/ RUN \ uv pip install \ -e ./homeassistant \ diff --git a/Dockerfile.dev b/Dockerfile.dev index cdb2db56267..1248979211b 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile@sha256:2780b5c3bab67f1f76c781860de469442999ed1a0d7992a5efdf2cffc0e3d769 FROM mcr.microsoft.com/vscode/devcontainers/base:debian SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 7821caac749..506a15e6c92 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -1,7 +1,5 @@ """Start Home Assistant.""" -from __future__ import annotations - import argparse from contextlib import suppress import faulthandler diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index e16c29ceaa8..71d1f48590a 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -1,7 +1,5 @@ """Provide an authentication layer for Home Assistant.""" -from __future__ import annotations - import asyncio from collections import OrderedDict from collections.abc import Mapping diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 429aad09edb..13ecdfc657a 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -1,7 +1,5 @@ """Storage for auth models.""" -from __future__ import annotations - from datetime import timedelta import hmac import itertools diff --git a/homeassistant/auth/jwt_wrapper.py b/homeassistant/auth/jwt_wrapper.py index 464df006f5f..fdb60aa2454 100644 --- a/homeassistant/auth/jwt_wrapper.py +++ b/homeassistant/auth/jwt_wrapper.py @@ -5,25 +5,31 @@ we can cache the result of the decode of valid tokens to speed up the process. """ -from __future__ import annotations - +from collections.abc import Container, Iterable, Sequence from datetime import timedelta -from functools import lru_cache, partial -from typing import Any +from functools import lru_cache +from typing import Any, override -from jwt import DecodeError, PyJWS, PyJWT +from jwt import DecodeError, PyJWK, PyJWS, PyJWT +from jwt.algorithms import AllowedPublicKeys +from jwt.types import Options from homeassistant.util.json import json_loads JWT_TOKEN_CACHE_SIZE = 16 MAX_TOKEN_SIZE = 8192 -_VERIFY_KEYS = ("signature", "exp", "nbf", "iat", "aud", "iss", "sub", "jti") - -_VERIFY_OPTIONS: dict[str, Any] = {f"verify_{key}": True for key in _VERIFY_KEYS} | { - "require": [] -} -_NO_VERIFY_OPTIONS = {f"verify_{key}": False for key in _VERIFY_KEYS} +_NO_VERIFY_OPTIONS = Options( + verify_signature=False, + verify_exp=False, + verify_nbf=False, + verify_iat=False, + verify_aud=False, + verify_iss=False, + verify_sub=False, + verify_jti=False, + require=[], +) class _PyJWSWithLoadCache(PyJWS): @@ -38,9 +44,6 @@ class _PyJWSWithLoadCache(PyJWS): return super()._load(jwt) -_jws = _PyJWSWithLoadCache() - - @lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE) def _decode_payload(json_payload: str) -> dict[str, Any]: """Decode the payload from a JWS dictionary.""" @@ -56,21 +59,12 @@ def _decode_payload(json_payload: str) -> dict[str, Any]: class _PyJWTWithVerify(PyJWT): """PyJWT with a fast decode implementation.""" - def decode_payload( - self, jwt: str, key: str, options: dict[str, Any], algorithms: list[str] - ) -> dict[str, Any]: - """Decode a JWT's payload.""" - if len(jwt) > MAX_TOKEN_SIZE: - # Avoid caching impossible tokens - raise DecodeError("Token too large") - return _decode_payload( - _jws.decode_complete( - jwt=jwt, - key=key, - algorithms=algorithms, - options=options, - )["payload"] - ) + def __init__(self) -> None: + """Initialize the PyJWT instance.""" + # We require exp and iat claims to be present + super().__init__(Options(require=["exp", "iat"])) + # Override the _jws instance with our cached version + self._jws = _PyJWSWithLoadCache() def verify_and_decode( self, @@ -79,37 +73,70 @@ class _PyJWTWithVerify(PyJWT): algorithms: list[str], issuer: str | None = None, leeway: float | timedelta = 0, - options: dict[str, Any] | None = None, + options: Options | None = None, ) -> dict[str, Any]: """Verify a JWT's signature and claims.""" - merged_options = {**_VERIFY_OPTIONS, **(options or {})} - payload = self.decode_payload( + return self.decode( jwt=jwt, key=key, - options=merged_options, algorithms=algorithms, - ) - # These should never be missing since we verify them - # but this is an additional safeguard to make sure - # nothing slips through. - assert "exp" in payload, "exp claim is required" - assert "iat" in payload, "iat claim is required" - self._validate_claims( - payload=payload, - options=merged_options, issuer=issuer, leeway=leeway, + options=options, ) - return payload + + @override + def decode( + self, + jwt: str | bytes, + key: AllowedPublicKeys | PyJWK | str | bytes = "", + algorithms: Sequence[str] | None = None, + options: Options | None = None, + verify: bool | None = None, + detached_payload: bytes | None = None, + audience: str | Iterable[str] | None = None, + subject: str | None = None, + issuer: str | Container[str] | None = None, + leeway: float | timedelta = 0, + **kwargs: Any, + ) -> dict[str, Any]: + """Decode a JWT, verifying the signature and claims.""" + if len(jwt) > MAX_TOKEN_SIZE: + # Avoid caching impossible tokens + raise DecodeError("Token too large") + return super().decode( + jwt=jwt, + key=key, + algorithms=algorithms, + options=options, + verify=verify, + detached_payload=detached_payload, + audience=audience, + subject=subject, + issuer=issuer, + leeway=leeway, + **kwargs, + ) + + @override + def _decode_payload(self, decoded: dict[str, Any]) -> dict[str, Any]: + return _decode_payload(decoded["payload"]) _jwt = _PyJWTWithVerify() verify_and_decode = _jwt.verify_and_decode -unverified_hs256_token_decode = lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)( - partial( - _jwt.decode_payload, key="", algorithms=["HS256"], options=_NO_VERIFY_OPTIONS + + +@lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE) +def unverified_hs256_token_decode(jwt: str) -> dict[str, Any]: + """Decode a JWT without verifying the signature.""" + return _jwt.decode( + jwt=jwt, + key="", + algorithms=["HS256"], + options=_NO_VERIFY_OPTIONS, ) -) + __all__ = [ "unverified_hs256_token_decode", diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index 0edc187e24d..da1e52bf98a 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -1,7 +1,5 @@ """Pluggable auth modules for Home Assistant.""" -from __future__ import annotations - import logging import types from typing import Any diff --git a/homeassistant/auth/mfa_modules/insecure_example.py b/homeassistant/auth/mfa_modules/insecure_example.py index fc696fe1b63..792629da556 100644 --- a/homeassistant/auth/mfa_modules/insecure_example.py +++ b/homeassistant/auth/mfa_modules/insecure_example.py @@ -1,7 +1,5 @@ """Example auth module.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 4de6d8915e6..02ecc5c3082 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -3,8 +3,6 @@ Sending HOTP through notify service """ -from __future__ import annotations - import asyncio import logging from typing import Any, cast diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 4c328571d0e..2815577b2cc 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -1,7 +1,5 @@ """Time-based One Time Password auth module.""" -from __future__ import annotations - import asyncio from io import BytesIO from typing import Any, cast diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index f92ed38ad85..5646d23d461 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -1,7 +1,5 @@ """Auth models.""" -from __future__ import annotations - from datetime import datetime, timedelta from ipaddress import IPv4Address, IPv6Address import secrets diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 6498483a19a..cebdf7f5a8f 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -1,8 +1,7 @@ """Permissions for Home Assistant.""" -from __future__ import annotations - -from collections.abc import Callable +from collections.abc import Callable, Iterable +from typing import TYPE_CHECKING import voluptuous as vol @@ -13,6 +12,9 @@ from .models import PermissionLookup from .types import PolicyType from .util import test_all +if TYPE_CHECKING: + from ..models import User + POLICY_SCHEMA = vol.Schema({vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA}) __all__ = [ @@ -22,10 +24,21 @@ __all__ = [ "PermissionLookup", "PolicyPermissions", "PolicyType", + "filter_entity_ids_by_permission", "merge_policies", ] +def filter_entity_ids_by_permission( + user: User, entity_ids: Iterable[str], key: str +) -> list[str]: + """Filter entity IDs to those the user can access for the given policy key.""" + if user.is_admin or user.permissions.access_all_entities(key): + return list(entity_ids) + check_entity = user.permissions.check_entity + return [entity_id for entity_id in entity_ids if check_entity(entity_id, key)] + + class AbstractPermissions: """Default permissions class.""" diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index dbe2fea0021..62a236c0b0c 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -1,7 +1,5 @@ """Entity permissions.""" -from __future__ import annotations - from collections import OrderedDict from collections.abc import Callable diff --git a/homeassistant/auth/permissions/events.py b/homeassistant/auth/permissions/events.py index ca0af1624ba..8802c36e112 100644 --- a/homeassistant/auth/permissions/events.py +++ b/homeassistant/auth/permissions/events.py @@ -1,7 +1,5 @@ """Permission for events.""" -from __future__ import annotations - from typing import Any, Final from homeassistant.const import ( diff --git a/homeassistant/auth/permissions/merge.py b/homeassistant/auth/permissions/merge.py index d0d43e2f088..e9dc71f29c7 100644 --- a/homeassistant/auth/permissions/merge.py +++ b/homeassistant/auth/permissions/merge.py @@ -1,7 +1,5 @@ """Merging of policies.""" -from __future__ import annotations - from typing import cast from .types import CategoryType, PolicyType diff --git a/homeassistant/auth/permissions/models.py b/homeassistant/auth/permissions/models.py index 086fdd7bd76..acd558b6895 100644 --- a/homeassistant/auth/permissions/models.py +++ b/homeassistant/auth/permissions/models.py @@ -1,7 +1,5 @@ """Models for permissions.""" -from __future__ import annotations - from typing import TYPE_CHECKING import attr diff --git a/homeassistant/auth/permissions/util.py b/homeassistant/auth/permissions/util.py index e1d1f660d75..e4a28bf1730 100644 --- a/homeassistant/auth/permissions/util.py +++ b/homeassistant/auth/permissions/util.py @@ -1,7 +1,5 @@ """Helpers to deal with permissions.""" -from __future__ import annotations - from collections.abc import Callable from functools import wraps from typing import cast diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 1155e77b407..d1e9512f34f 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -1,7 +1,5 @@ """Auth providers for Home Assistant.""" -from __future__ import annotations - from collections.abc import Mapping import logging import types diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py index 74630d925e1..c6c14a14241 100644 --- a/homeassistant/auth/providers/command_line.py +++ b/homeassistant/auth/providers/command_line.py @@ -1,7 +1,5 @@ """Auth provider that validates credentials via an external command.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index 88f0950400a..13feddf9523 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -1,7 +1,5 @@ """Home Assistant auth provider.""" -from __future__ import annotations - import asyncio import base64 from collections.abc import Mapping diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py index a92f5b55848..b8a61c4c042 100644 --- a/homeassistant/auth/providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -1,7 +1,5 @@ """Example auth provider.""" -from __future__ import annotations - from collections.abc import Mapping import hmac diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index 83299859de9..4da99374554 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -4,8 +4,6 @@ It shows list of users if access from trusted network. Abort login flow if not access from trusted network. """ -from __future__ import annotations - from collections.abc import Mapping from ipaddress import ( IPv4Address, diff --git a/homeassistant/backup_restore.py b/homeassistant/backup_restore.py index 6800851c182..040a4b58634 100644 --- a/homeassistant/backup_restore.py +++ b/homeassistant/backup_restore.py @@ -1,7 +1,5 @@ """Home Assistant module to handle restoring backups.""" -from __future__ import annotations - from collections.abc import Iterable from dataclasses import dataclass import json diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index ce411280772..7592ef78244 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -1,7 +1,5 @@ """Provide methods to bootstrap a Home Assistant instance.""" -from __future__ import annotations - import asyncio from collections import defaultdict import contextlib @@ -238,7 +236,9 @@ DEFAULT_INTEGRATIONS = { "timer", # # Base platforms: - *BASE_PLATFORMS, + # Note: Calendar and todo are not included to prevent them from registering + # their frontend panels when there are no calendar or todo integrations. + *(BASE_PLATFORMS - {"calendar", "todo"}), # # Integrations providing triggers and conditions for base platforms: "air_quality", diff --git a/homeassistant/brands/bega.json b/homeassistant/brands/bega.json new file mode 100644 index 00000000000..7ff9ece9715 --- /dev/null +++ b/homeassistant/brands/bega.json @@ -0,0 +1,5 @@ +{ + "domain": "bega", + "name": "BEGA", + "iot_standards": ["zigbee"] +} diff --git a/homeassistant/brands/denon.json b/homeassistant/brands/denon.json index a60750e1a31..d4f68c1306e 100644 --- a/homeassistant/brands/denon.json +++ b/homeassistant/brands/denon.json @@ -1,5 +1,5 @@ { "domain": "denon", "name": "Denon", - "integrations": ["denon", "denonavr", "heos"] + "integrations": ["denon", "denonavr", "denon_rs232", "heos"] } diff --git a/homeassistant/brands/honeywell.json b/homeassistant/brands/honeywell.json index 37cd6d8ce73..001db20de07 100644 --- a/homeassistant/brands/honeywell.json +++ b/homeassistant/brands/honeywell.json @@ -1,5 +1,5 @@ { "domain": "honeywell", "name": "Honeywell", - "integrations": ["lyric", "evohome", "honeywell"] + "integrations": ["lyric", "evohome", "honeywell", "honeywell_string_lights"] } diff --git a/homeassistant/brands/sensereo.json b/homeassistant/brands/sensereo.json new file mode 100644 index 00000000000..4825bd55326 --- /dev/null +++ b/homeassistant/brands/sensereo.json @@ -0,0 +1,5 @@ +{ + "domain": "sensereo", + "name": "Sensereo", + "iot_standards": ["matter"] +} diff --git a/homeassistant/brands/ubiquiti.json b/homeassistant/brands/ubiquiti.json index bcc63495324..47f7bad2261 100644 --- a/homeassistant/brands/ubiquiti.json +++ b/homeassistant/brands/ubiquiti.json @@ -6,6 +6,7 @@ "unifi", "unifi_access", "unifi_direct", + "unifi_discovery", "unifiled", "unifiprotect" ] diff --git a/homeassistant/brands/victron.json b/homeassistant/brands/victron.json index e8508b389aa..8d01e456b69 100644 --- a/homeassistant/brands/victron.json +++ b/homeassistant/brands/victron.json @@ -1,5 +1,5 @@ { "domain": "victron", "name": "Victron", - "integrations": ["victron_ble", "victron_remote_monitoring"] + "integrations": ["victron_gx", "victron_ble", "victron_remote_monitoring"] } diff --git a/homeassistant/brands/zunzunbee.json b/homeassistant/brands/zunzunbee.json new file mode 100644 index 00000000000..d1c67a9cfc9 --- /dev/null +++ b/homeassistant/brands/zunzunbee.json @@ -0,0 +1,5 @@ +{ + "domain": "zunzunbee", + "name": "Zunzunbee", + "iot_standards": ["zigbee"] +} diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 525fc60e930..3205cd4d4a7 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -1,7 +1,5 @@ """Support for the Abode Security System.""" -from __future__ import annotations - from dataclasses import dataclass, field from functools import partial from pathlib import Path @@ -30,7 +28,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import CONF_POLLING, DOMAIN, DOMAIN_DATA, LOGGER +from .const import CONF_POLLING, DOMAIN, LOGGER from .services import async_setup_services ATTR_DEVICE_NAME = "device_name" @@ -67,13 +65,16 @@ class AbodeSystem: logout_listener: CALLBACK_TYPE | None = None +type AbodeConfigEntry = ConfigEntry[AbodeSystem] + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Abode component.""" async_setup_services(hass) return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: AbodeConfigEntry) -> bool: """Set up Abode integration from a config entry.""" username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] @@ -99,50 +100,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (AbodeException, ConnectTimeout, HTTPError) as ex: raise ConfigEntryNotReady(f"Unable to connect to Abode: {ex}") from ex - hass.data[DOMAIN_DATA] = AbodeSystem(abode, polling) + entry.runtime_data = AbodeSystem(abode, polling) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await setup_hass_events(hass) - await hass.async_add_executor_job(setup_abode_events, hass) + await setup_hass_events(hass, entry) + await hass.async_add_executor_job(setup_abode_events, hass, entry) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +def _shutdown_client(abode: Abode) -> None: + """Shutdown client.""" + abode.events.stop() + abode.logout() + + +async def async_unload_entry(hass: HomeAssistant, entry: AbodeConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.stop) - await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.logout) + await hass.async_add_executor_job(_shutdown_client, entry.runtime_data.abode) - if logout_listener := hass.data[DOMAIN_DATA].logout_listener: + if logout_listener := entry.runtime_data.logout_listener: logout_listener() - hass.data.pop(DOMAIN_DATA) return unload_ok -async def setup_hass_events(hass: HomeAssistant) -> None: +async def setup_hass_events(hass: HomeAssistant, entry: AbodeConfigEntry) -> None: """Home Assistant start and stop callbacks.""" def logout(event: Event) -> None: """Logout of Abode.""" - if not hass.data[DOMAIN_DATA].polling: - hass.data[DOMAIN_DATA].abode.events.stop() + if not entry.runtime_data.polling: + entry.runtime_data.abode.events.stop() - hass.data[DOMAIN_DATA].abode.logout() + entry.runtime_data.abode.logout() LOGGER.info("Logged out of Abode") - if not hass.data[DOMAIN_DATA].polling: - await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.start) + if not entry.runtime_data.polling: + await hass.async_add_executor_job(entry.runtime_data.abode.events.start) - hass.data[DOMAIN_DATA].logout_listener = hass.bus.async_listen_once( + entry.runtime_data.logout_listener = hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, logout ) -def setup_abode_events(hass: HomeAssistant) -> None: +def setup_abode_events(hass: HomeAssistant, entry: AbodeConfigEntry) -> None: """Event callbacks.""" def event_callback(event: str, event_json: dict[str, str]) -> None: @@ -179,6 +184,6 @@ def setup_abode_events(hass: HomeAssistant) -> None: ] for event in events: - hass.data[DOMAIN_DATA].abode.events.add_event_callback( + entry.runtime_data.abode.events.add_event_callback( event, partial(event_callback, event) ) diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py index 161ef315b80..8b743d1481c 100644 --- a/homeassistant/components/abode/alarm_control_panel.py +++ b/homeassistant/components/abode/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Abode Security System alarm control panels.""" -from __future__ import annotations - from jaraco.abode.devices.alarm import Alarm from homeassistant.components.alarm_control_panel import ( @@ -9,21 +7,20 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN_DATA +from . import AbodeConfigEntry from .entity import AbodeDevice async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AbodeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode alarm control panel device.""" - data = hass.data[DOMAIN_DATA] + data = entry.runtime_data async_add_entities( [AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))] ) diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index a3fce63ddf2..9c2c878a3f4 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Abode Security System binary sensors.""" -from __future__ import annotations - from typing import cast from jaraco.abode.devices.binary_sensor import BinarySensor @@ -10,22 +8,21 @@ 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 homeassistant.util.enum import try_parse_enum -from .const import DOMAIN_DATA +from . import AbodeConfigEntry from .entity import AbodeDevice async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AbodeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode binary sensor devices.""" - data = hass.data[DOMAIN_DATA] + data = entry.runtime_data device_types = [ "connectivity", diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index 4d81fba9172..1b0d63a2613 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -1,7 +1,5 @@ """Support for Abode Security System cameras.""" -from __future__ import annotations - from datetime import timedelta from typing import Any, cast @@ -12,14 +10,13 @@ import requests from requests.models import Response from homeassistant.components.camera import Camera -from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import Throttle -from . import AbodeSystem -from .const import DOMAIN_DATA, LOGGER +from . import AbodeConfigEntry, AbodeSystem +from .const import LOGGER from .entity import AbodeDevice MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) @@ -27,11 +24,11 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AbodeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode camera devices.""" - data = hass.data[DOMAIN_DATA] + data = entry.runtime_data async_add_entities( AbodeCamera(data, device, timeline.CAPTURE_IMAGE) diff --git a/homeassistant/components/abode/config_flow.py b/homeassistant/components/abode/config_flow.py index 8077c6037f7..5762c3046cf 100644 --- a/homeassistant/components/abode/config_flow.py +++ b/homeassistant/components/abode/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Abode Security System component.""" -from __future__ import annotations - from collections.abc import Mapping from http import HTTPStatus from typing import Any, cast diff --git a/homeassistant/components/abode/const.py b/homeassistant/components/abode/const.py index 0279b89f7d4..7515b83ea07 100644 --- a/homeassistant/components/abode/const.py +++ b/homeassistant/components/abode/const.py @@ -1,19 +1,10 @@ """Constants for the Abode Security System component.""" -from __future__ import annotations - import logging -from typing import TYPE_CHECKING - -from homeassistant.util.hass_dict import HassKey - -if TYPE_CHECKING: - from . import AbodeSystem LOGGER = logging.getLogger(__package__) DOMAIN = "abode" -DOMAIN_DATA: HassKey[AbodeSystem] = HassKey(DOMAIN) ATTRIBUTION = "Data provided by goabode.com" CONF_POLLING = "polling" diff --git a/homeassistant/components/abode/cover.py b/homeassistant/components/abode/cover.py index dd70ea765ba..1a81d04b09e 100644 --- a/homeassistant/components/abode/cover.py +++ b/homeassistant/components/abode/cover.py @@ -5,21 +5,20 @@ from typing import Any from jaraco.abode.devices.cover import Cover from homeassistant.components.cover import CoverEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN_DATA +from . import AbodeConfigEntry from .entity import AbodeDevice async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AbodeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode cover devices.""" - data = hass.data[DOMAIN_DATA] + data = entry.runtime_data async_add_entities( AbodeCover(data, device) diff --git a/homeassistant/components/abode/entity.py b/homeassistant/components/abode/entity.py index d74e7990328..087a87d2ce7 100644 --- a/homeassistant/components/abode/entity.py +++ b/homeassistant/components/abode/entity.py @@ -7,7 +7,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from . import AbodeSystem -from .const import ATTRIBUTION, DOMAIN, DOMAIN_DATA +from .const import ATTRIBUTION, DOMAIN class AbodeEntity(Entity): @@ -29,7 +29,7 @@ class AbodeEntity(Entity): self._update_connection_status, ) - self.hass.data[DOMAIN_DATA].entity_ids.add(self.entity_id) + self._data.entity_ids.add(self.entity_id) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from Abode connection status updates.""" diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py index cee402d3cb6..442f8c2ce6b 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -1,7 +1,5 @@ """Support for Abode Security System lights.""" -from __future__ import annotations - from math import ceil from typing import Any @@ -16,21 +14,20 @@ 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_DATA +from . import AbodeConfigEntry from .entity import AbodeDevice async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AbodeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode light devices.""" - data = hass.data[DOMAIN_DATA] + data = entry.runtime_data async_add_entities( AbodeLight(data, device) diff --git a/homeassistant/components/abode/lock.py b/homeassistant/components/abode/lock.py index 9e4c45453e5..aad7838cab5 100644 --- a/homeassistant/components/abode/lock.py +++ b/homeassistant/components/abode/lock.py @@ -5,21 +5,20 @@ from typing import Any from jaraco.abode.devices.lock import Lock 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 .const import DOMAIN_DATA +from . import AbodeConfigEntry from .entity import AbodeDevice async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AbodeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode lock devices.""" - data = hass.data[DOMAIN_DATA] + data = entry.runtime_data async_add_entities( AbodeLock(data, device) diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index 72eee7eccf2..21d318641cd 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -1,7 +1,5 @@ """Support for Abode Security System sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import cast @@ -14,13 +12,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import AbodeSystem -from .const import DOMAIN_DATA +from . import AbodeConfigEntry, AbodeSystem from .entity import AbodeDevice ABODE_TEMPERATURE_UNIT_HA_UNIT = { @@ -66,11 +62,11 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AbodeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode sensor devices.""" - data = hass.data[DOMAIN_DATA] + data = entry.runtime_data async_add_entities( AbodeSensor(data, device, description) diff --git a/homeassistant/components/abode/services.py b/homeassistant/components/abode/services.py index 5b2a05f5228..06cbe3cdffd 100644 --- a/homeassistant/components/abode/services.py +++ b/homeassistant/components/abode/services.py @@ -1,16 +1,20 @@ """Support for the Abode Security System.""" -from __future__ import annotations +from typing import TYPE_CHECKING from jaraco.abode.exceptions import Exception as AbodeException import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send -from .const import DOMAIN, DOMAIN_DATA, LOGGER +from .const import DOMAIN, LOGGER + +if TYPE_CHECKING: + from . import AbodeConfigEntry, AbodeSystem ATTR_SETTING = "setting" ATTR_VALUE = "value" @@ -25,13 +29,21 @@ CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) +def _get_abode_system(hass: HomeAssistant) -> AbodeSystem: + """Return the Abode system for the loaded config entry.""" + entries: list[AbodeConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN) + if not entries: + raise ServiceValidationError("Abode integration is not loaded") + return entries[0].runtime_data + + def _change_setting(call: ServiceCall) -> None: """Change an Abode system setting.""" setting = call.data[ATTR_SETTING] value = call.data[ATTR_VALUE] try: - call.hass.data[DOMAIN_DATA].abode.set_setting(setting, value) + _get_abode_system(call.hass).abode.set_setting(setting, value) except AbodeException as ex: LOGGER.warning(ex) @@ -42,7 +54,7 @@ def _capture_image(call: ServiceCall) -> None: target_entities = [ entity_id - for entity_id in call.hass.data[DOMAIN_DATA].entity_ids + for entity_id in _get_abode_system(call.hass).entity_ids if entity_id in entity_ids ] @@ -57,7 +69,7 @@ def _trigger_automation(call: ServiceCall) -> None: target_entities = [ entity_id - for entity_id in call.hass.data[DOMAIN_DATA].entity_ids + for entity_id in _get_abode_system(call.hass).entity_ids if entity_id in entity_ids ] diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py index 7eecea514fe..008b39b6b46 100644 --- a/homeassistant/components/abode/switch.py +++ b/homeassistant/components/abode/switch.py @@ -1,18 +1,15 @@ """Support for Abode Security System switches.""" -from __future__ import annotations - from typing import Any, cast from jaraco.abode.devices.switch import Switch from homeassistant.components.switch import SwitchEntity -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 DOMAIN_DATA +from . import AbodeConfigEntry from .entity import AbodeAutomation, AbodeDevice DEVICE_TYPES = ["switch", "valve"] @@ -20,11 +17,11 @@ DEVICE_TYPES = ["switch", "valve"] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AbodeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Abode switch devices.""" - data = hass.data[DOMAIN_DATA] + data = entry.runtime_data entities: list[SwitchEntity] = [ AbodeSwitch(data, device) diff --git a/homeassistant/components/acaia/coordinator.py b/homeassistant/components/acaia/coordinator.py index 9f29c844235..7c5ba9790ad 100644 --- a/homeassistant/components/acaia/coordinator.py +++ b/homeassistant/components/acaia/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Acaia integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/acaia/diagnostics.py b/homeassistant/components/acaia/diagnostics.py index 2d9f4511804..0a0aec5f0ee 100644 --- a/homeassistant/components/acaia/diagnostics.py +++ b/homeassistant/components/acaia/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Acaia.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/acaia/sensor.py b/homeassistant/components/acaia/sensor.py index f62b93ddf1d..6694bbb9966 100644 --- a/homeassistant/components/acaia/sensor.py +++ b/homeassistant/components/acaia/sensor.py @@ -143,4 +143,4 @@ class AcaiaRestoreSensor(AcaiaEntity, RestoreSensor): @property def available(self) -> bool: """Return True if entity is available.""" - return super().available or self._restored_data is not None + return super().available or self.native_value is not None diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index de8f2ab93a9..00d4a4187b1 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -1,7 +1,5 @@ """The AccuWeather component.""" -from __future__ import annotations - import asyncio import logging diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index a56391e9c4f..999f7691b5b 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -1,10 +1,8 @@ """Adds config flow for AccuWeather.""" -from __future__ import annotations - from asyncio import timeout from collections.abc import Mapping -from typing import Any +from typing import TYPE_CHECKING, Any from accuweather import AccuWeather, ApiError, InvalidApiKeyError, RequestsExceededError from aiohttp import ClientError @@ -12,7 +10,7 @@ from aiohttp.client_exceptions import ClientConnectorError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -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.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -55,8 +53,11 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() + if TYPE_CHECKING: + assert accuweather.location_name is not None + return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input + title=accuweather.location_name, data=user_input ) return self.async_show_form( @@ -70,9 +71,6 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN): vol.Optional( CONF_LONGITUDE, default=self.hass.config.longitude ): cv.longitude, - vol.Optional( - CONF_NAME, default=self.hass.config.location_name - ): str, } ), errors=errors, diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index a7083c4ae0f..c4a70ec18f2 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -1,7 +1,5 @@ """Constants for AccuWeather integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Final diff --git a/homeassistant/components/accuweather/coordinator.py b/homeassistant/components/accuweather/coordinator.py index 3c4991d2c59..b0791d4ff81 100644 --- a/homeassistant/components/accuweather/coordinator.py +++ b/homeassistant/components/accuweather/coordinator.py @@ -1,7 +1,5 @@ """The AccuWeather coordinator.""" -from __future__ import annotations - from asyncio import timeout from collections.abc import Awaitable, Callable from dataclasses import dataclass @@ -64,7 +62,7 @@ class AccuWeatherObservationDataUpdateCoordinator( """Initialize.""" self.accuweather = accuweather self.location_key = accuweather.location_key - name = config_entry.data[CONF_NAME] + name = config_entry.data.get(CONF_NAME) or config_entry.title if TYPE_CHECKING: assert self.location_key is not None @@ -122,7 +120,7 @@ class AccuWeatherForecastDataUpdateCoordinator( self.accuweather = accuweather self.location_key = accuweather.location_key self._fetch_method = fetch_method - name = config_entry.data[CONF_NAME] + name = config_entry.data.get(CONF_NAME) or config_entry.title if TYPE_CHECKING: assert self.location_key is not None diff --git a/homeassistant/components/accuweather/diagnostics.py b/homeassistant/components/accuweather/diagnostics.py index 9f35c47b886..21f72b7adea 100644 --- a/homeassistant/components/accuweather/diagnostics.py +++ b/homeassistant/components/accuweather/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for AccuWeather.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 415df402d55..4d16a830877 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -1,7 +1,5 @@ """Support for the AccuWeather service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index 221452a63c9..ac6d15bd477 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -25,8 +25,7 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "latitude": "[%key:common::config_flow::data::latitude%]", - "longitude": "[%key:common::config_flow::data::longitude%]", - "name": "[%key:common::config_flow::data::name%]" + "longitude": "[%key:common::config_flow::data::longitude%]" }, "data_description": { "api_key": "API key generated in the AccuWeather APIs portal." diff --git a/homeassistant/components/accuweather/system_health.py b/homeassistant/components/accuweather/system_health.py index 99335a9dd8f..dd4406755a0 100644 --- a/homeassistant/components/accuweather/system_health.py +++ b/homeassistant/components/accuweather/system_health.py @@ -1,7 +1,5 @@ """Provide info to system health.""" -from __future__ import annotations - from typing import Any from accuweather.const import ENDPOINT diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index dd6b3f4b0a4..3146052b74d 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -1,7 +1,5 @@ """Support for the AccuWeather service.""" -from __future__ import annotations - from typing import cast from homeassistant.components.weather import ( diff --git a/homeassistant/components/acer_projector/const.py b/homeassistant/components/acer_projector/const.py index 95e32dc97d4..367040273b7 100644 --- a/homeassistant/components/acer_projector/const.py +++ b/homeassistant/components/acer_projector/const.py @@ -1,15 +1,14 @@ """Use serial protocol of Acer projector to obtain state of the projector.""" -from __future__ import annotations - from typing import Final from homeassistant.const import STATE_OFF, STATE_ON +CONF_READ_TIMEOUT: Final = "timeout" CONF_WRITE_TIMEOUT: Final = "write_timeout" DEFAULT_NAME: Final = "Acer Projector" -DEFAULT_TIMEOUT: Final = 1 +DEFAULT_READ_TIMEOUT: Final = 1 DEFAULT_WRITE_TIMEOUT: Final = 1 ECO_MODE: Final = "ECO Mode" diff --git a/homeassistant/components/acer_projector/manifest.json b/homeassistant/components/acer_projector/manifest.json index 026374bf53d..45d6256f0e8 100644 --- a/homeassistant/components/acer_projector/manifest.json +++ b/homeassistant/components/acer_projector/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/acer_projector", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["pyserial==3.5"] + "requirements": ["serialx==1.7.1"] } diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py index 846164202d8..157ae037a38 100644 --- a/homeassistant/components/acer_projector/switch.py +++ b/homeassistant/components/acer_projector/switch.py @@ -1,12 +1,10 @@ """Use serial protocol of Acer projector to obtain state of the projector.""" -from __future__ import annotations - import logging import re from typing import Any -import serial +from serialx import Serial, SerialException import voluptuous as vol from homeassistant.components.switch import ( @@ -16,21 +14,22 @@ from homeassistant.components.switch import ( from homeassistant.const import ( CONF_FILENAME, CONF_NAME, - CONF_TIMEOUT, STATE_OFF, STATE_ON, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( CMD_DICT, + CONF_READ_TIMEOUT, CONF_WRITE_TIMEOUT, DEFAULT_NAME, - DEFAULT_TIMEOUT, + DEFAULT_READ_TIMEOUT, DEFAULT_WRITE_TIMEOUT, ECO_MODE, ICON, @@ -45,7 +44,7 @@ PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( { vol.Required(CONF_FILENAME): cv.isdevice, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_READ_TIMEOUT, default=DEFAULT_READ_TIMEOUT): cv.positive_int, vol.Optional( CONF_WRITE_TIMEOUT, default=DEFAULT_WRITE_TIMEOUT ): cv.positive_int, @@ -62,10 +61,10 @@ def setup_platform( """Connect with serial port and return Acer Projector.""" serial_port = config[CONF_FILENAME] name = config[CONF_NAME] - timeout = config[CONF_TIMEOUT] + read_timeout = config[CONF_READ_TIMEOUT] write_timeout = config[CONF_WRITE_TIMEOUT] - add_entities([AcerSwitch(serial_port, name, timeout, write_timeout)], True) + add_entities([AcerSwitch(serial_port, name, read_timeout, write_timeout)], True) class AcerSwitch(SwitchEntity): @@ -77,14 +76,14 @@ class AcerSwitch(SwitchEntity): self, serial_port: str, name: str, - timeout: int, + read_timeout: int, write_timeout: int, ) -> None: """Init of the Acer projector.""" - self.serial = serial.Serial( - port=serial_port, timeout=timeout, write_timeout=write_timeout - ) self._serial_port = serial_port + self._read_timeout = read_timeout + self._write_timeout = write_timeout + self._attr_name = name self._attributes = { LAMP_HOURS: STATE_UNKNOWN, @@ -94,22 +93,26 @@ class AcerSwitch(SwitchEntity): def _write_read(self, msg: str) -> str: """Write to the projector and read the return.""" - ret = "" + # Sometimes the projector won't answer for no reason or the projector # was disconnected during runtime. # This way the projector can be reconnected and will still work try: - if not self.serial.is_open: - self.serial.open() - self.serial.write(msg.encode("utf-8")) - # Size is an experience value there is no real limit. - # AFAIK there is no limit and no end character so we will usually - # need to wait for timeout - ret = self.serial.read_until(size=20).decode("utf-8") - except serial.SerialException: - _LOGGER.error("Problem communicating with %s", self._serial_port) - self.serial.close() - return ret + with Serial.from_url( + self._serial_port, + read_timeout=self._read_timeout, + write_timeout=self._write_timeout, + ) as serial: + serial.write(msg.encode("utf-8")) + + # Size is an experience value there is no real limit. + # AFAIK there is no limit and no end character so we will usually + # need to wait for timeout + return serial.read_until(size=20).decode("utf-8") + except (OSError, SerialException, TimeoutError) as exc: + raise HomeAssistantError( + f"Problem communicating with {self._serial_port}" + ) from exc def _write_read_format(self, msg: str) -> str: """Write msg, obtain answer and format output.""" diff --git a/homeassistant/components/acmeda/config_flow.py b/homeassistant/components/acmeda/config_flow.py index 785906ebf2a..78f54eae0ac 100644 --- a/homeassistant/components/acmeda/config_flow.py +++ b/homeassistant/components/acmeda/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Rollease Acmeda Automate Pulse Hub.""" -from __future__ import annotations - from asyncio import timeout from contextlib import suppress from typing import Any diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index d09ba4bac08..d2ab4cb018f 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -1,7 +1,5 @@ """Support for Acmeda Roller Blinds.""" -from __future__ import annotations - from typing import Any from homeassistant.components.cover import ( diff --git a/homeassistant/components/acmeda/entity.py b/homeassistant/components/acmeda/entity.py index 63432886b4d..32e78373e78 100644 --- a/homeassistant/components/acmeda/entity.py +++ b/homeassistant/components/acmeda/entity.py @@ -1,7 +1,5 @@ """Base class for Acmeda Roller Blinds.""" -from __future__ import annotations - import aiopulse from homeassistant.core import callback diff --git a/homeassistant/components/acmeda/helpers.py b/homeassistant/components/acmeda/helpers.py index 4c0f9b32cff..e43bd6210a9 100644 --- a/homeassistant/components/acmeda/helpers.py +++ b/homeassistant/components/acmeda/helpers.py @@ -1,7 +1,5 @@ """Helper functions for Acmeda Pulse.""" -from __future__ import annotations - from typing import TYPE_CHECKING from aiopulse import Roller diff --git a/homeassistant/components/acmeda/hub.py b/homeassistant/components/acmeda/hub.py index 4f2e4f4f63f..267ff1d981c 100644 --- a/homeassistant/components/acmeda/hub.py +++ b/homeassistant/components/acmeda/hub.py @@ -1,7 +1,5 @@ """Code to handle a Pulse Hub.""" -from __future__ import annotations - import asyncio from collections.abc import Callable diff --git a/homeassistant/components/acmeda/sensor.py b/homeassistant/components/acmeda/sensor.py index 515146f3d1a..ffdbf6b9b1e 100644 --- a/homeassistant/components/acmeda/sensor.py +++ b/homeassistant/components/acmeda/sensor.py @@ -1,7 +1,5 @@ """Support for Acmeda Roller Blind Batteries.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/actiontec/__init__.py b/homeassistant/components/actiontec/__init__.py index fa59cc87063..adcb750a794 100644 --- a/homeassistant/components/actiontec/__init__.py +++ b/homeassistant/components/actiontec/__init__.py @@ -1 +1 @@ -"""The actiontec component.""" +"""The Actiontec integration.""" diff --git a/homeassistant/components/actiontec/const.py b/homeassistant/components/actiontec/const.py index beff47f9811..291c7fc8d8d 100644 --- a/homeassistant/components/actiontec/const.py +++ b/homeassistant/components/actiontec/const.py @@ -1,7 +1,5 @@ """Support for Actiontec MI424WR (Verizon FIOS) routers.""" -from __future__ import annotations - import re from typing import Final diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py index 41876ce478f..5bdc1a84077 100644 --- a/homeassistant/components/actiontec/device_tracker.py +++ b/homeassistant/components/actiontec/device_tracker.py @@ -1,7 +1,5 @@ """Support for Actiontec MI424WR (Verizon FIOS) routers.""" -from __future__ import annotations - import logging from typing import Final diff --git a/homeassistant/components/actron_air/__init__.py b/homeassistant/components/actron_air/__init__.py index 7048e76512f..f8b460dd027 100644 --- a/homeassistant/components/actron_air/__init__.py +++ b/homeassistant/components/actron_air/__init__.py @@ -1,11 +1,7 @@ """The Actron Air integration.""" -from actron_neo_api import ( - ActronAirACSystem, - ActronAirAPI, - ActronAirAPIError, - ActronAirAuthError, -) +from actron_neo_api import ActronAirAPI, ActronAirAPIError, ActronAirAuthError +from actron_neo_api.models.system import ActronAirSystemInfo from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.core import HomeAssistant @@ -25,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> """Set up Actron Air integration from a config entry.""" api = ActronAirAPI(refresh_token=entry.data[CONF_API_TOKEN]) - systems: list[ActronAirACSystem] = [] + systems: list[ActronAirSystemInfo] = [] try: systems = await api.get_ac_systems() @@ -36,14 +32,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> translation_key="auth_error", ) from err except ActronAirAPIError as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_connection_error", + ) from err system_coordinators: dict[str, ActronAirSystemCoordinator] = {} for system in systems: coordinator = ActronAirSystemCoordinator(hass, entry, api, system) - _LOGGER.debug("Setting up coordinator for system: %s", system["serial"]) + _LOGGER.debug("Setting up coordinator for system: %s", system.serial) await coordinator.async_config_entry_first_refresh() - system_coordinators[system["serial"]] = coordinator + system_coordinators[system.serial] = coordinator entry.runtime_data = ActronAirRuntimeData( api=api, diff --git a/homeassistant/components/actron_air/climate.py b/homeassistant/components/actron_air/climate.py index 8c928fcc5a9..efae5467a2a 100644 --- a/homeassistant/components/actron_air/climate.py +++ b/homeassistant/components/actron_air/climate.py @@ -15,10 +15,12 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator -from .entity import ActronAirAcEntity, ActronAirZoneEntity, handle_actron_api_errors +from .entity import ActronAirAcEntity, ActronAirZoneEntity, actron_air_command PARALLEL_UPDATES = 0 @@ -36,6 +38,7 @@ HVAC_MODE_MAPPING_ACTRONAIR_TO_HA = { "HEAT": HVACMode.HEAT, "FAN": HVACMode.FAN_ONLY, "AUTO": HVACMode.AUTO, + "DRY": HVACMode.DRY, "OFF": HVACMode.OFF, } HVAC_MODE_MAPPING_HA_TO_ACTRONAIR = { @@ -77,7 +80,6 @@ class ActronAirClimateEntity(ClimateEntity): ) _attr_name = None _attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values()) - _attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values()) class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity): @@ -91,6 +93,17 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity): super().__init__(coordinator) self._attr_unique_id = self._serial_number + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of supported HVAC modes.""" + modes = [ + HVAC_MODE_MAPPING_ACTRONAIR_TO_HA[mode] + for mode in self._status.user_aircon_settings.supported_modes + if mode in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA + ] + modes.append(HVACMode.OFF) + return modes + @property def min_temp(self) -> float: """Return the minimum temperature that can be set.""" @@ -134,25 +147,29 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity): @property def target_temperature(self) -> float: """Return the target temperature.""" - return self._status.user_aircon_settings.temperature_setpoint_cool_c + return self._status.user_aircon_settings.current_setpoint - @handle_actron_api_errors + @actron_air_command async def async_set_fan_mode(self, fan_mode: str) -> None: """Set a new fan mode.""" - api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode) + api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR[fan_mode] await self._status.user_aircon_settings.set_fan_mode(api_fan_mode) - @handle_actron_api_errors + @actron_air_command async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC mode.""" - ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode) + ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR[hvac_mode] await self._status.ac_system.set_system_mode(ac_mode) - @handle_actron_api_errors + @actron_air_command async def async_set_temperature(self, **kwargs: Any) -> None: """Set the temperature.""" - temp = kwargs.get(ATTR_TEMPERATURE) - await self._status.user_aircon_settings.set_temperature(temperature=temp) + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="temperature_missing", + ) + await self._status.user_aircon_settings.set_temperature(temperature=temperature) class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity): @@ -173,6 +190,18 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity): super().__init__(coordinator, zone) self._attr_unique_id: str = self._zone_identifier + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of supported HVAC modes.""" + status = self.coordinator.data + modes = [ + HVAC_MODE_MAPPING_ACTRONAIR_TO_HA[mode] + for mode in status.user_aircon_settings.supported_modes + if mode in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA + ] + modes.append(HVACMode.OFF) + return modes + @property def min_temp(self) -> float: """Return the minimum temperature that can be set.""" @@ -210,15 +239,20 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity): @property def target_temperature(self) -> float | None: """Return the target temperature.""" - return self._zone.temperature_setpoint_cool_c + return self._zone.current_setpoint - @handle_actron_api_errors + @actron_air_command async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC mode.""" is_enabled = hvac_mode != HVACMode.OFF await self._zone.enable(is_enabled) - @handle_actron_api_errors + @actron_air_command async def async_set_temperature(self, **kwargs: Any) -> None: """Set the temperature.""" - await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE)) + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="temperature_missing", + ) + await self._zone.set_temperature(temperature=temperature) diff --git a/homeassistant/components/actron_air/config_flow.py b/homeassistant/components/actron_air/config_flow.py index 3faefe7590f..8b5a6f4bc5a 100644 --- a/homeassistant/components/actron_air/config_flow.py +++ b/homeassistant/components/actron_air/config_flow.py @@ -6,7 +6,12 @@ from typing import Any from actron_neo_api import ActronAirAPI, ActronAirAuthError -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_API_TOKEN from homeassistant.exceptions import HomeAssistantError @@ -23,7 +28,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN): self._user_code: str = "" self._verification_uri: str = "" self._expires_minutes: str = "30" - self.login_task: asyncio.Task | None = None + self.login_task: asyncio.Task[None] | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -38,10 +43,10 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.error("OAuth2 flow failed: %s", err) return self.async_abort(reason="oauth2_error") - self._device_code = device_code_response["device_code"] - self._user_code = device_code_response["user_code"] - self._verification_uri = device_code_response["verification_uri_complete"] - self._expires_minutes = str(device_code_response["expires_in"] // 60) + self._device_code = device_code_response.device_code + self._user_code = device_code_response.user_code + self._verification_uri = device_code_response.verification_uri_complete + self._expires_minutes = str(device_code_response.expires_in // 60) async def _wait_for_authorization() -> None: """Wait for the user to authorize the device.""" @@ -94,7 +99,7 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.error("Error getting user info: %s", err) return self.async_abort(reason="oauth2_error") - unique_id = str(user_data["id"]) + unique_id = user_data.sub await self.async_set_unique_id(unique_id) # Check if this is a reauth flow @@ -105,9 +110,17 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN): data_updates={CONF_API_TOKEN: self._api.refresh_token_value}, ) + # Check if this is a reconfigure flow + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates={CONF_API_TOKEN: self._api.refresh_token_value}, + ) + self._abort_if_unique_id_configured() return self.async_create_entry( - title=user_data["email"], + title=user_data.email, data={CONF_API_TOKEN: self._api.refresh_token_value}, ) @@ -138,6 +151,20 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="reauth_confirm") + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration request.""" + return await self.async_step_reconfigure_confirm() + + async def async_step_reconfigure_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reconfiguration dialog.""" + if user_input is not None: + return await self.async_step_user() + return self.async_show_form(step_id="reconfigure_confirm") + async def async_step_connection_error( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/actron_air/coordinator.py b/homeassistant/components/actron_air/coordinator.py index a69f7ab56b0..bd762ce9512 100644 --- a/homeassistant/components/actron_air/coordinator.py +++ b/homeassistant/components/actron_air/coordinator.py @@ -1,17 +1,15 @@ """Coordinator for Actron Air integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta from actron_neo_api import ( - ActronAirACSystem, ActronAirAPI, ActronAirAPIError, ActronAirAuthError, ActronAirStatus, ) +from actron_neo_api.models.system import ActronAirSystemInfo from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -38,7 +36,7 @@ class ActronAirRuntimeData: type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData] -class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]): +class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirStatus]): """System coordinator for Actron Air integration.""" def __init__( @@ -46,7 +44,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]): hass: HomeAssistant, entry: ActronAirConfigEntry, api: ActronAirAPI, - system: ActronAirACSystem, + system: ActronAirSystemInfo, ) -> None: """Initialize the coordinator.""" super().__init__( @@ -57,7 +55,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]): config_entry=entry, ) self.system = system - self.serial_number = system["serial"] + self.serial_number = system.serial self.api = api self.status = self.api.state_manager.get_status(self.serial_number) self.last_seen = dt_util.utcnow() @@ -78,7 +76,14 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]): translation_placeholders={"error": repr(err)}, ) from err - self.status = self.api.state_manager.get_status(self.serial_number) + status = self.api.state_manager.get_status(self.serial_number) + if status is None: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": "Status not available"}, + ) + self.status = status self.last_seen = dt_util.utcnow() return self.status diff --git a/homeassistant/components/actron_air/diagnostics.py b/homeassistant/components/actron_air/diagnostics.py new file mode 100644 index 00000000000..4a715fcc3c7 --- /dev/null +++ b/homeassistant/components/actron_air/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for Actron Air.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant + +from .coordinator import ActronAirConfigEntry + +TO_REDACT = {CONF_API_TOKEN, "master_serial", "serial_number", "serial"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + entry: ActronAirConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinators: dict[int, Any] = {} + for idx, coordinator in enumerate(entry.runtime_data.system_coordinators.values()): + coordinators[idx] = { + "system": async_redact_data( + coordinator.system.model_dump(mode="json"), TO_REDACT + ), + "status": async_redact_data( + coordinator.data.model_dump(mode="json", exclude={"last_known_state"}), + TO_REDACT, + ), + } + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "coordinators": coordinators, + } diff --git a/homeassistant/components/actron_air/entity.py b/homeassistant/components/actron_air/entity.py index 7f62c53516e..ec69232101b 100644 --- a/homeassistant/components/actron_air/entity.py +++ b/homeassistant/components/actron_air/entity.py @@ -14,13 +14,17 @@ from .const import DOMAIN from .coordinator import ActronAirSystemCoordinator -def handle_actron_api_errors[_EntityT: ActronAirEntity, **_P]( +def actron_air_command[_EntityT: ActronAirEntity, **_P]( func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]], ) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]: - """Decorate Actron Air API calls to handle ActronAirAPIError exceptions.""" + """Decorator for Actron Air API calls. + + Handles ActronAirAPIError exceptions, and requests a coordinator update + to update the status of the devices as soon as possible. + """ @wraps(func) - async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: + async def wrapper(self: _EntityT, /, *args: _P.args, **kwargs: _P.kwargs) -> None: """Wrap API calls with exception handling.""" try: await func(self, *args, **kwargs) @@ -30,6 +34,7 @@ def handle_actron_api_errors[_EntityT: ActronAirEntity, **_P]( translation_key="api_error", translation_placeholders={"error": str(err)}, ) from err + self.coordinator.async_set_updated_data(self.coordinator.data) return wrapper diff --git a/homeassistant/components/actron_air/manifest.json b/homeassistant/components/actron_air/manifest.json index 724ff101cb9..06978d83b46 100644 --- a/homeassistant/components/actron_air/manifest.json +++ b/homeassistant/components/actron_air/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["actron-neo-api==0.4.1"] + "requirements": ["actron-neo-api==0.5.6"] } diff --git a/homeassistant/components/actron_air/quality_scale.yaml b/homeassistant/components/actron_air/quality_scale.yaml index 240b3e4b185..4d8dd96fa86 100644 --- a/homeassistant/components/actron_air/quality_scale.yaml +++ b/homeassistant/components/actron_air/quality_scale.yaml @@ -41,7 +41,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: This integration uses DHCP discovery, however is cloud polling. Therefore there is no information to update. @@ -54,19 +54,13 @@ rules: docs-troubleshooting: done docs-use-cases: done dynamic-devices: todo - entity-category: - status: exempt - comment: This integration does not use entity categories. - entity-device-class: - status: exempt - comment: This integration does not use entity device classes. - entity-disabled-by-default: - status: exempt - comment: Not required for this integration at this stage. - entity-translations: todo - exception-translations: todo - icon-translations: todo - reconfiguration-flow: todo + entity-category: done + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done repair-issues: status: exempt comment: This integration does not have any known issues that require repair. @@ -75,4 +69,4 @@ rules: # Platinum async-dependency: done inject-websession: todo - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/actron_air/strings.json b/homeassistant/components/actron_air/strings.json index 9e22a6ffb86..cf7c2fc677b 100644 --- a/homeassistant/components/actron_air/strings.json +++ b/homeassistant/components/actron_air/strings.json @@ -4,7 +4,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "oauth2_error": "Failed to start authentication flow", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "wrong_account": "You must reauthenticate with the same Actron Air account that was originally configured." + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "wrong_account": "You must authenticate with the same Actron Air account that was originally configured." }, "error": { "oauth2_error": "Failed to start authentication flow. Please try again later." @@ -22,6 +23,10 @@ "description": "Your Actron Air authentication has expired. Select continue to reauthenticate with your Actron Air account. You will be prompted to log in again to restore the connection.", "title": "Authentication expired" }, + "reconfigure_confirm": { + "description": "Reconfigure your Actron Air account. You will be prompted to log in again. Note: you must use the same account that was originally configured.", + "title": "Reconfigure Actron Air" + }, "timeout": { "data": {}, "description": "The authentication process timed out. Please try again.", @@ -55,6 +60,12 @@ "auth_error": { "message": "Authentication failed, please reauthenticate" }, + "setup_connection_error": { + "message": "Failed to connect to the Actron Air API" + }, + "temperature_missing": { + "message": "Provide a temperature value when adjusting the climate entity." + }, "update_error": { "message": "An error occurred while retrieving data from the Actron Air API: {error}" } diff --git a/homeassistant/components/actron_air/switch.py b/homeassistant/components/actron_air/switch.py index 44efe6c9f74..113be86171d 100644 --- a/homeassistant/components/actron_air/switch.py +++ b/homeassistant/components/actron_air/switch.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator -from .entity import ActronAirAcEntity, handle_actron_api_errors +from .entity import ActronAirAcEntity, actron_air_command PARALLEL_UPDATES = 0 @@ -105,12 +105,12 @@ class ActronAirSwitch(ActronAirAcEntity, SwitchEntity): """Return true if the switch is on.""" return self.entity_description.is_on_fn(self.coordinator) - @handle_actron_api_errors + @actron_air_command async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" await self.entity_description.set_fn(self.coordinator, True) - @handle_actron_api_errors + @actron_air_command async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" await self.entity_description.set_fn(self.coordinator, False) diff --git a/homeassistant/components/adax/__init__.py b/homeassistant/components/adax/__init__.py index 22da669c57e..2072dcec706 100644 --- a/homeassistant/components/adax/__init__.py +++ b/homeassistant/components/adax/__init__.py @@ -1,7 +1,5 @@ """The Adax integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py index 62ddb213e2a..5e2540ce38d 100755 --- a/homeassistant/components/adax/climate.py +++ b/homeassistant/components/adax/climate.py @@ -1,7 +1,5 @@ """Support for Adax wifi-enabled home heaters.""" -from __future__ import annotations - from typing import Any, cast from adax import Adax diff --git a/homeassistant/components/adax/config_flow.py b/homeassistant/components/adax/config_flow.py index 9611c5d5017..f6958205adc 100644 --- a/homeassistant/components/adax/config_flow.py +++ b/homeassistant/components/adax/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Adax integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/adax/sensor.py b/homeassistant/components/adax/sensor.py index ca5001b3a10..b959edf265b 100644 --- a/homeassistant/components/adax/sensor.py +++ b/homeassistant/components/adax/sensor.py @@ -1,7 +1,5 @@ """Support for Adax energy sensors.""" -from __future__ import annotations - from dataclasses import dataclass from typing import cast diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index 8726162f4b9..ea6ad5a0bb3 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -1,7 +1,5 @@ """Support for AdGuard Home.""" -from __future__ import annotations - from dataclasses import dataclass from adguardhome import AdGuardHome, AdGuardHomeConnectionError diff --git a/homeassistant/components/adguard/config_flow.py b/homeassistant/components/adguard/config_flow.py index 4c7d688fd0c..b732d1501e1 100644 --- a/homeassistant/components/adguard/config_flow.py +++ b/homeassistant/components/adguard/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the AdGuard Home integration.""" -from __future__ import annotations - from typing import Any from adguardhome import AdGuardHome, AdGuardHomeConnectionError diff --git a/homeassistant/components/adguard/entity.py b/homeassistant/components/adguard/entity.py index bdc89e23f57..ce663f54dc4 100644 --- a/homeassistant/components/adguard/entity.py +++ b/homeassistant/components/adguard/entity.py @@ -1,7 +1,5 @@ """AdGuard Home base entity.""" -from __future__ import annotations - from adguardhome import AdGuardHomeError from homeassistant.config_entries import SOURCE_HASSIO diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index f1af8ac32a4..c656b5b4310 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -1,7 +1,5 @@ """Support for AdGuard Home sensors.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py index 5128102a955..0b952f0f5d6 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -1,7 +1,5 @@ """Support for AdGuard Home switches.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/adguard/update.py b/homeassistant/components/adguard/update.py index 74d427e973f..4713a6a251d 100644 --- a/homeassistant/components/adguard/update.py +++ b/homeassistant/components/adguard/update.py @@ -1,7 +1,5 @@ """AdGuard Home Update platform.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/ads/binary_sensor.py b/homeassistant/components/ads/binary_sensor.py index 560d090caf0..27d07658c62 100644 --- a/homeassistant/components/ads/binary_sensor.py +++ b/homeassistant/components/ads/binary_sensor.py @@ -1,7 +1,5 @@ """Support for ADS binary sensors.""" -from __future__ import annotations - import pyads import voluptuous as vol diff --git a/homeassistant/components/ads/const.py b/homeassistant/components/ads/const.py index ea78fb41785..3ae3cd052ac 100644 --- a/homeassistant/components/ads/const.py +++ b/homeassistant/components/ads/const.py @@ -1,7 +1,5 @@ """Support for Automation Device Specification (ADS).""" -from __future__ import annotations - from enum import StrEnum from typing import TYPE_CHECKING diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index 15d5b3a7d09..141a934f5bd 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -1,7 +1,5 @@ """Support for ADS covers.""" -from __future__ import annotations - from typing import Any import pyads diff --git a/homeassistant/components/ads/light.py b/homeassistant/components/ads/light.py index 63d699a0055..1c849a15b18 100644 --- a/homeassistant/components/ads/light.py +++ b/homeassistant/components/ads/light.py @@ -1,7 +1,5 @@ """Support for ADS light sources.""" -from __future__ import annotations - from typing import Any import pyads diff --git a/homeassistant/components/ads/select.py b/homeassistant/components/ads/select.py index e31e089d669..5d887aa39c7 100644 --- a/homeassistant/components/ads/select.py +++ b/homeassistant/components/ads/select.py @@ -1,7 +1,5 @@ """Support for ADS select entities.""" -from __future__ import annotations - import pyads import voluptuous as vol diff --git a/homeassistant/components/ads/sensor.py b/homeassistant/components/ads/sensor.py index 0fd1b84ffd1..f1bb87a87f2 100644 --- a/homeassistant/components/ads/sensor.py +++ b/homeassistant/components/ads/sensor.py @@ -1,7 +1,5 @@ """Support for ADS sensors.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.sensor import ( diff --git a/homeassistant/components/ads/switch.py b/homeassistant/components/ads/switch.py index 2506757e9d2..6fe0cbd9dca 100644 --- a/homeassistant/components/ads/switch.py +++ b/homeassistant/components/ads/switch.py @@ -1,7 +1,5 @@ """Support for ADS switch platform.""" -from __future__ import annotations - from typing import Any import pyads diff --git a/homeassistant/components/ads/valve.py b/homeassistant/components/ads/valve.py index a251e14b3c3..d7373453691 100644 --- a/homeassistant/components/ads/valve.py +++ b/homeassistant/components/ads/valve.py @@ -1,7 +1,5 @@ """Support for ADS valves.""" -from __future__ import annotations - import pyads import voluptuous as vol diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py index 28fdaa9b7e1..db2846b90a8 100644 --- a/homeassistant/components/advantage_air/binary_sensor.py +++ b/homeassistant/components/advantage_air/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor platform for Advantage Air integration.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 938bcb469a6..af243270207 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -1,7 +1,5 @@ """Climate platform for Advantage Air integration.""" -from __future__ import annotations - from decimal import Decimal import logging from typing import Any diff --git a/homeassistant/components/advantage_air/config_flow.py b/homeassistant/components/advantage_air/config_flow.py index df3ee1c3638..f18a697a10e 100644 --- a/homeassistant/components/advantage_air/config_flow.py +++ b/homeassistant/components/advantage_air/config_flow.py @@ -1,7 +1,5 @@ """Config Flow for Advantage Air integration.""" -from __future__ import annotations - from typing import Any from advantage_air import ApiError, advantage_air diff --git a/homeassistant/components/advantage_air/coordinator.py b/homeassistant/components/advantage_air/coordinator.py index 54628d4f4c3..db0f3526416 100644 --- a/homeassistant/components/advantage_air/coordinator.py +++ b/homeassistant/components/advantage_air/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Advantage Air integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/advantage_air/diagnostics.py b/homeassistant/components/advantage_air/diagnostics.py index d15ce57df5e..f2b6a6be9a2 100644 --- a/homeassistant/components/advantage_air/diagnostics.py +++ b/homeassistant/components/advantage_air/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Advantage Air.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index 59d72a7bacf..9bd20121538 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Advantage Air integration.""" -from __future__ import annotations - from decimal import Decimal from typing import Any diff --git a/homeassistant/components/advantage_air/services.py b/homeassistant/components/advantage_air/services.py index a64d1c9e225..ebcc4a699b7 100644 --- a/homeassistant/components/advantage_air/services.py +++ b/homeassistant/components/advantage_air/services.py @@ -1,7 +1,5 @@ """Services for Advantage Air integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index c3df75be2ce..0f142a2a01b 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -1,7 +1,5 @@ """Config flow for AEMET OpenData.""" -from __future__ import annotations - from typing import Any from aemet_opendata.exceptions import AuthError diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index b79a94d209d..a083146132b 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -1,7 +1,5 @@ """Constant values for the AEMET OpenData component.""" -from __future__ import annotations - from aemet_opendata.const import ( AOD_COND_CLEAR_NIGHT, AOD_COND_CLOUDY, diff --git a/homeassistant/components/aemet/coordinator.py b/homeassistant/components/aemet/coordinator.py index 2e8534c7466..3f8648794db 100644 --- a/homeassistant/components/aemet/coordinator.py +++ b/homeassistant/components/aemet/coordinator.py @@ -1,7 +1,5 @@ """Weather data coordinator for the AEMET OpenData service.""" -from __future__ import annotations - from asyncio import timeout from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/aemet/diagnostics.py b/homeassistant/components/aemet/diagnostics.py index b072309d4b8..2df10df4d42 100644 --- a/homeassistant/components/aemet/diagnostics.py +++ b/homeassistant/components/aemet/diagnostics.py @@ -1,7 +1,5 @@ """Support for the AEMET OpenData diagnostics.""" -from __future__ import annotations - from typing import Any from aemet_opendata.const import AOD_COORDS, AOD_IMG_BYTES diff --git a/homeassistant/components/aemet/entity.py b/homeassistant/components/aemet/entity.py index 562d82fd9c7..9ea15a4843f 100644 --- a/homeassistant/components/aemet/entity.py +++ b/homeassistant/components/aemet/entity.py @@ -1,7 +1,5 @@ """Entity classes for the AEMET OpenData integration.""" -from __future__ import annotations - from typing import Any from aemet_opendata.helpers import dict_nested_value diff --git a/homeassistant/components/aemet/image.py b/homeassistant/components/aemet/image.py index ba9986a5ccc..4fa3c6df423 100644 --- a/homeassistant/components/aemet/image.py +++ b/homeassistant/components/aemet/image.py @@ -1,7 +1,5 @@ """Support for the AEMET OpenData images.""" -from __future__ import annotations - from typing import Final from aemet_opendata.const import AOD_DATETIME, AOD_IMG_BYTES, AOD_IMG_TYPE, AOD_RADAR diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 2e7e977cf3d..20df424c8ab 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -1,7 +1,5 @@ """Support for the AEMET OpenData service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/aftership/__init__.py b/homeassistant/components/aftership/__init__.py index 9632217e960..b4c7cb53319 100644 --- a/homeassistant/components/aftership/__init__.py +++ b/homeassistant/components/aftership/__init__.py @@ -1,7 +1,5 @@ """The AfterShip integration.""" -from __future__ import annotations - from pyaftership import AfterShip, AfterShipException from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/aftership/config_flow.py b/homeassistant/components/aftership/config_flow.py index 99de28b2fc2..06ec384fa84 100644 --- a/homeassistant/components/aftership/config_flow.py +++ b/homeassistant/components/aftership/config_flow.py @@ -1,7 +1,5 @@ """Config flow for AfterShip integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/aftership/const.py b/homeassistant/components/aftership/const.py index c5d7b00a942..781e682308e 100644 --- a/homeassistant/components/aftership/const.py +++ b/homeassistant/components/aftership/const.py @@ -1,7 +1,5 @@ """Constants for the Aftership integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Final diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py index 7e0c6f524ab..377147d274d 100644 --- a/homeassistant/components/aftership/sensor.py +++ b/homeassistant/components/aftership/sensor.py @@ -1,7 +1,5 @@ """Support for non-delivered packages recorded in AfterShip.""" -from __future__ import annotations - import logging from typing import Any, Final diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index 0d9267e7739..6e2ee53585a 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Agent DVR Alarm Control Panels.""" -from __future__ import annotations - from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, diff --git a/homeassistant/components/agent_dvr/services.py b/homeassistant/components/agent_dvr/services.py index b9c5c0f7ec6..dcfd3b70155 100644 --- a/homeassistant/components/agent_dvr/services.py +++ b/homeassistant/components/agent_dvr/services.py @@ -1,7 +1,5 @@ """Services for Agent DVR.""" -from __future__ import annotations - from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import service diff --git a/homeassistant/components/ai_task/const.py b/homeassistant/components/ai_task/const.py index 978e6f3cfb9..58709a09e19 100644 --- a/homeassistant/components/ai_task/const.py +++ b/homeassistant/components/ai_task/const.py @@ -1,7 +1,5 @@ """Constants for the AI Task integration.""" -from __future__ import annotations - from enum import IntFlag from typing import TYPE_CHECKING, Final diff --git a/homeassistant/components/ai_task/media_source.py b/homeassistant/components/ai_task/media_source.py index 61a212be5b0..9f0e493b0ad 100644 --- a/homeassistant/components/ai_task/media_source.py +++ b/homeassistant/components/ai_task/media_source.py @@ -1,7 +1,5 @@ """Expose images as media sources.""" -from __future__ import annotations - from pathlib import Path from homeassistant.components.media_source import MediaSource, local_source @@ -25,7 +23,7 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource: hass.data[DATA_MEDIA_SOURCE] = source = local_source.LocalSource( hass, DOMAIN, - "AI Generated Images", + "AI generated images", {IMAGE_DIR: str(media_dir)}, f"/{DOMAIN}", ) diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index 1d27f75b6c7..b3952b101e9 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -1,7 +1,5 @@ """AI tasks to be handled by agents.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime, timedelta import io @@ -74,7 +72,8 @@ async def _resolve_attachments( resolved_attachments.append( conversation.Attachment( media_content_id=media_content_id, - mime_type=image_data.content_type, + mime_type=attachment.get("media_content_type") + or image_data.content_type, path=temp_filename, ) ) @@ -89,7 +88,7 @@ async def _resolve_attachments( resolved_attachments.append( conversation.Attachment( media_content_id=media_content_id, - mime_type=media.mime_type, + mime_type=attachment.get("media_content_type") or media.mime_type, path=media.path, ) ) diff --git a/homeassistant/components/air_quality/__init__.py b/homeassistant/components/air_quality/__init__.py index 1e2a0525f29..ef11742e41d 100644 --- a/homeassistant/components/air_quality/__init__.py +++ b/homeassistant/components/air_quality/__init__.py @@ -1,7 +1,5 @@ """Component for handling Air Quality data for your location.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final, final diff --git a/homeassistant/components/air_quality/conditions.yaml b/homeassistant/components/air_quality/conditions.yaml index 97b7c1056da..ef7b6b18c55 100644 --- a/homeassistant/components/air_quality/conditions.yaml +++ b/homeassistant/components/air_quality/conditions.yaml @@ -4,11 +4,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: # --- Unit lists for multi-unit pollutants --- @@ -249,6 +252,7 @@ .condition_binary_common: &condition_binary_common fields: behavior: *condition_behavior + for: *condition_for is_gas_detected: <<: *condition_binary_common @@ -280,6 +284,7 @@ is_co_value: target: *target_co_sensor fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: @@ -294,6 +299,7 @@ is_ozone_value: target: *target_ozone fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: @@ -308,6 +314,7 @@ is_voc_value: target: *target_voc fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: @@ -322,6 +329,7 @@ is_voc_ratio_value: target: *target_voc_ratio fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: @@ -336,6 +344,7 @@ is_no_value: target: *target_no fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: @@ -350,6 +359,7 @@ is_no2_value: target: *target_no2 fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: @@ -364,6 +374,7 @@ is_so2_value: target: *target_so2 fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: @@ -380,6 +391,7 @@ is_co2_value: target: *target_co2 fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: @@ -392,6 +404,7 @@ is_pm1_value: target: *target_pm1 fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: @@ -404,6 +417,7 @@ is_pm25_value: target: *target_pm25 fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: @@ -416,6 +430,7 @@ is_pm4_value: target: *target_pm4 fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: @@ -428,6 +443,7 @@ is_pm10_value: target: *target_pm10 fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: @@ -440,6 +456,7 @@ is_n2o_value: target: *target_n2o fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: diff --git a/homeassistant/components/air_quality/strings.json b/homeassistant/components/air_quality/strings.json index f3369398b34..54af78fca3b 100644 --- a/homeassistant/components/air_quality/strings.json +++ b/homeassistant/components/air_quality/strings.json @@ -1,25 +1,23 @@ { "common": { - "condition_behavior_description": "How the value should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "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": { "is_co2_value": { "description": "Tests the carbon dioxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -29,8 +27,10 @@ "description": "Tests if one or more carbon monoxide sensors are cleared.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" } }, "name": "Carbon monoxide cleared" @@ -39,8 +39,10 @@ "description": "Tests if one or more carbon monoxide sensors are detecting carbon monoxide.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" } }, "name": "Carbon monoxide detected" @@ -49,11 +51,12 @@ "description": "Tests the carbon monoxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -63,8 +66,10 @@ "description": "Tests if one or more gas sensors are cleared.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" } }, "name": "Gas cleared" @@ -73,8 +78,10 @@ "description": "Tests if one or more gas sensors are detecting gas.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" } }, "name": "Gas detected" @@ -83,11 +90,12 @@ "description": "Tests the nitrous oxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -97,11 +105,12 @@ "description": "Tests the nitrogen dioxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -111,11 +120,12 @@ "description": "Tests the nitrogen monoxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -125,11 +135,12 @@ "description": "Tests the ozone level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -139,11 +150,12 @@ "description": "Tests the PM10 level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -153,11 +165,12 @@ "description": "Tests the PM1 level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -167,11 +180,12 @@ "description": "Tests the PM2.5 level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -181,11 +195,12 @@ "description": "Tests the PM4 level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -195,8 +210,10 @@ "description": "Tests if one or more smoke sensors are cleared.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" } }, "name": "Smoke cleared" @@ -205,8 +222,10 @@ "description": "Tests if one or more smoke sensors are detecting smoke.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" } }, "name": "Smoke detected" @@ -215,11 +234,12 @@ "description": "Tests the sulphur dioxide level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -229,11 +249,12 @@ "description": "Tests the volatile organic compounds ratio of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, @@ -243,39 +264,24 @@ "description": "Tests the volatile organic compounds level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::condition_behavior_description%]", "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::condition_threshold_description%]", "name": "[%key:component::air_quality::common::condition_threshold_name%]" } }, "name": "Volatile organic compounds value" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Air Quality", "triggers": { "co2_changed": { "description": "Triggers after one or more carbon dioxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -285,11 +291,12 @@ "description": "Triggers after one or more carbon dioxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -299,7 +306,6 @@ "description": "Triggers after one or more carbon monoxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -309,8 +315,10 @@ "description": "Triggers after one or more carbon monoxide sensors stop detecting carbon monoxide.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" } }, "name": "Carbon monoxide cleared" @@ -319,11 +327,12 @@ "description": "Triggers after one or more carbon monoxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -333,8 +342,10 @@ "description": "Triggers after one or more carbon monoxide sensors start detecting carbon monoxide.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" } }, "name": "Carbon monoxide detected" @@ -343,8 +354,10 @@ "description": "Triggers after one or more gas sensors stop detecting gas.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" } }, "name": "Gas cleared" @@ -353,8 +366,10 @@ "description": "Triggers after one or more gas sensors start detecting gas.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" } }, "name": "Gas detected" @@ -363,7 +378,6 @@ "description": "Triggers after one or more nitrous oxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -373,11 +387,12 @@ "description": "Triggers after one or more nitrous oxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -387,7 +402,6 @@ "description": "Triggers after one or more nitrogen dioxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -397,11 +411,12 @@ "description": "Triggers after one or more nitrogen dioxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -411,7 +426,6 @@ "description": "Triggers after one or more nitrogen monoxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -421,11 +435,12 @@ "description": "Triggers after one or more nitrogen monoxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -435,7 +450,6 @@ "description": "Triggers after one or more ozone levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -445,11 +459,12 @@ "description": "Triggers after one or more ozone levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -459,7 +474,6 @@ "description": "Triggers after one or more PM10 levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -469,11 +483,12 @@ "description": "Triggers after one or more PM10 levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -483,7 +498,6 @@ "description": "Triggers after one or more PM1 levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -493,11 +507,12 @@ "description": "Triggers after one or more PM1 levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -507,7 +522,6 @@ "description": "Triggers after one or more PM2.5 levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -517,11 +531,12 @@ "description": "Triggers after one or more PM2.5 levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -531,7 +546,6 @@ "description": "Triggers after one or more PM4 levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -541,11 +555,12 @@ "description": "Triggers after one or more PM4 levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -555,8 +570,10 @@ "description": "Triggers after one or more smoke sensors stop detecting smoke.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" } }, "name": "Smoke cleared" @@ -565,8 +582,10 @@ "description": "Triggers after one or more smoke sensors start detecting smoke.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" } }, "name": "Smoke detected" @@ -575,7 +594,6 @@ "description": "Triggers after one or more sulphur dioxide levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -585,11 +603,12 @@ "description": "Triggers after one or more sulphur dioxide levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -599,7 +618,6 @@ "description": "Triggers after one or more volatile organic compound levels change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -609,11 +627,12 @@ "description": "Triggers after one or more volatile organic compounds levels cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -623,7 +642,6 @@ "description": "Triggers after one or more volatile organic compound ratios change.", "fields": { "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, @@ -633,11 +651,12 @@ "description": "Triggers after one or more volatile organic compounds ratios cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::air_quality::common::trigger_behavior_description%]", "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]", "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } }, diff --git a/homeassistant/components/air_quality/triggers.yaml b/homeassistant/components/air_quality/triggers.yaml index e453aeeb875..1992cc1f039 100644 --- a/homeassistant/components/air_quality/triggers.yaml +++ b/homeassistant/components/air_quality/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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: # --- Unit lists for multi-unit pollutants --- @@ -163,6 +164,7 @@ # Binary sensor detected/cleared trigger fields .trigger_binary_fields: &trigger_binary_fields behavior: *trigger_behavior + for: *trigger_for # --- Binary sensor targets --- @@ -294,6 +296,7 @@ co_crossed_threshold: target: *target_co_sensor fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: @@ -320,6 +323,7 @@ co2_crossed_threshold: target: *target_co2 fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: @@ -344,6 +348,7 @@ pm1_crossed_threshold: target: *target_pm1 fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: @@ -368,6 +373,7 @@ pm25_crossed_threshold: target: *target_pm25 fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: @@ -392,6 +398,7 @@ pm4_crossed_threshold: target: *target_pm4 fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: @@ -416,6 +423,7 @@ pm10_crossed_threshold: target: *target_pm10 fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: @@ -442,6 +450,7 @@ ozone_crossed_threshold: target: *target_ozone fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: @@ -470,6 +479,7 @@ voc_crossed_threshold: target: *target_voc fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: @@ -498,6 +508,7 @@ voc_ratio_crossed_threshold: target: *target_voc_ratio fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: @@ -526,6 +537,7 @@ no_crossed_threshold: target: *target_no fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: @@ -554,6 +566,7 @@ no2_crossed_threshold: target: *target_no2 fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: @@ -580,6 +593,7 @@ n2o_crossed_threshold: target: *target_n2o fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: @@ -606,6 +620,7 @@ so2_crossed_threshold: target: *target_so2 fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: diff --git a/homeassistant/components/airgradient/__init__.py b/homeassistant/components/airgradient/__init__.py index 8f7fd86847d..7d713dfed9f 100644 --- a/homeassistant/components/airgradient/__init__.py +++ b/homeassistant/components/airgradient/__init__.py @@ -1,7 +1,5 @@ """The Airgradient integration.""" -from __future__ import annotations - from airgradient import AirGradientClient from homeassistant.const import CONF_HOST, Platform diff --git a/homeassistant/components/airgradient/coordinator.py b/homeassistant/components/airgradient/coordinator.py index 9ee103b3a90..cf93a1aa901 100644 --- a/homeassistant/components/airgradient/coordinator.py +++ b/homeassistant/components/airgradient/coordinator.py @@ -1,7 +1,5 @@ """Define an object to manage fetching AirGradient data.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/airgradient/diagnostics.py b/homeassistant/components/airgradient/diagnostics.py index dfc3262193a..5eca72d3784 100644 --- a/homeassistant/components/airgradient/diagnostics.py +++ b/homeassistant/components/airgradient/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Airgradient.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index 7c26f6062d6..a6a94f1ccbe 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -1,7 +1,5 @@ """The Airly integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index d3f2240a37c..f4707727e65 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Airly.""" -from __future__ import annotations - from asyncio import timeout from http import HTTPStatus from typing import Any @@ -12,11 +10,11 @@ from airly.exceptions import AirlyError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -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.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_USE_NEAREST, DOMAIN, NO_AIRLY_SENSORS +from .const import CONF_USE_NEAREST, DEFAULT_NAME, DOMAIN, NO_AIRLY_SENSORS DESCRIPTION_PLACEHOLDERS = { "developer_registration_url": "https://developer.airly.eu/register", @@ -45,16 +43,16 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN): try: location_point_valid = await check_location( websession, - user_input["api_key"], - user_input["latitude"], - user_input["longitude"], + user_input[CONF_API_KEY], + user_input[CONF_LATITUDE], + user_input[CONF_LONGITUDE], ) if not location_point_valid: location_nearest_valid = await check_location( websession, - user_input["api_key"], - user_input["latitude"], - user_input["longitude"], + user_input[CONF_API_KEY], + user_input[CONF_LATITUDE], + user_input[CONF_LONGITUDE], use_nearest=True, ) except AirlyError as err: @@ -68,7 +66,7 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="wrong_location") use_nearest = True return self.async_create_entry( - title=user_input[CONF_NAME], + title=DEFAULT_NAME, data={**user_input, CONF_USE_NEAREST: use_nearest}, ) @@ -83,9 +81,6 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN): vol.Optional( CONF_LONGITUDE, default=self.hass.config.longitude ): cv.longitude, - vol.Optional( - CONF_NAME, default=self.hass.config.location_name - ): str, } ), errors=errors, diff --git a/homeassistant/components/airly/const.py b/homeassistant/components/airly/const.py index 5939bfa62de..9655503a085 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -1,7 +1,5 @@ """Constants for Airly integration.""" -from __future__ import annotations - from typing import Final ATTR_API_ADVICE: Final = "ADVICE" @@ -37,3 +35,5 @@ MAX_UPDATE_INTERVAL: Final = 90 MIN_UPDATE_INTERVAL: Final = 5 NO_AIRLY_SENSORS: Final = "There are no Airly sensors in this area yet." URL = "https://airly.org/map/#{latitude},{longitude}" + +DEFAULT_NAME: Final = "Airly" diff --git a/homeassistant/components/airly/diagnostics.py b/homeassistant/components/airly/diagnostics.py index 6e9e55a4311..676fd5f15db 100644 --- a/homeassistant/components/airly/diagnostics.py +++ b/homeassistant/components/airly/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Airly.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 2aa99d9c792..3ba4484a24e 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -1,7 +1,5 @@ """Support for the Airly sensor service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any @@ -127,7 +125,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = ( ), AirlySensorEntityDescription( key=ATTR_API_CO, - translation_key="co", + device_class=SensorDeviceClass.CO, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=0, @@ -178,7 +176,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Airly sensor entities based on a config entry.""" - name = entry.data[CONF_NAME] + name = entry.data.get(CONF_NAME) or entry.title coordinator = entry.runtime_data diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json index 6f53c7ed23c..4c3a50b194b 100644 --- a/homeassistant/components/airly/strings.json +++ b/homeassistant/components/airly/strings.json @@ -13,8 +13,7 @@ "data": { "api_key": "[%key:common::config_flow::data::api_key%]", "latitude": "[%key:common::config_flow::data::latitude%]", - "longitude": "[%key:common::config_flow::data::longitude%]", - "name": "[%key:common::config_flow::data::name%]" + "longitude": "[%key:common::config_flow::data::longitude%]" }, "description": "To generate API key go to {developer_registration_url}" } @@ -24,9 +23,6 @@ "sensor": { "caqi": { "name": "Common air quality index" - }, - "co": { - "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" } } }, diff --git a/homeassistant/components/airly/system_health.py b/homeassistant/components/airly/system_health.py index 629cb255122..427c4b9b56d 100644 --- a/homeassistant/components/airly/system_health.py +++ b/homeassistant/components/airly/system_health.py @@ -1,7 +1,5 @@ """Provide info to system health.""" -from __future__ import annotations - from typing import Any from airly import Airly diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 331fdb729f5..f29ec0d14c4 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -1,7 +1,5 @@ """Config flow for AirNow integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/airnow/diagnostics.py b/homeassistant/components/airnow/diagnostics.py index bd6dab9dc47..57459536346 100644 --- a/homeassistant/components/airnow/diagnostics.py +++ b/homeassistant/components/airnow/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for AirNow.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index db579de4976..f1205b28463 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -1,7 +1,5 @@ """Support for the AirNow sensor service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json index 20aed65cc0f..2b3d417abcf 100644 --- a/homeassistant/components/airnow/strings.json +++ b/homeassistant/components/airnow/strings.json @@ -17,7 +17,13 @@ "longitude": "[%key:common::config_flow::data::longitude%]", "radius": "Station radius (miles; optional)" }, - "description": "To generate API key go to {api_key_url}" + "data_description": { + "api_key": "To generate an API key, go to {api_key_url}.", + "latitude": "The latitude of your location.", + "longitude": "The longitude of your location.", + "radius": "The radius in miles around your location to search for reporting stations." + }, + "description": "To generate an API key, go to {api_key_url}." } } }, diff --git a/homeassistant/components/airobot/__init__.py b/homeassistant/components/airobot/__init__.py index abd3f5e53b3..757b4320627 100644 --- a/homeassistant/components/airobot/__init__.py +++ b/homeassistant/components/airobot/__init__.py @@ -1,7 +1,5 @@ """The Airobot integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/airobot/button.py b/homeassistant/components/airobot/button.py index 44c161fe03e..a4c063a16f1 100644 --- a/homeassistant/components/airobot/button.py +++ b/homeassistant/components/airobot/button.py @@ -1,7 +1,5 @@ """Button platform for Airobot integration.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/airobot/climate.py b/homeassistant/components/airobot/climate.py index 6570b56fe9d..adb5944d979 100644 --- a/homeassistant/components/airobot/climate.py +++ b/homeassistant/components/airobot/climate.py @@ -1,7 +1,5 @@ """Climate platform for Airobot thermostat.""" -from __future__ import annotations - from typing import Any from pyairobotrest.const import ( diff --git a/homeassistant/components/airobot/config_flow.py b/homeassistant/components/airobot/config_flow.py index f86b96ca94c..ee63af2957f 100644 --- a/homeassistant/components/airobot/config_flow.py +++ b/homeassistant/components/airobot/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Airobot integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from dataclasses import dataclass diff --git a/homeassistant/components/airobot/coordinator.py b/homeassistant/components/airobot/coordinator.py index ea7a974aa5d..722eb2642b2 100644 --- a/homeassistant/components/airobot/coordinator.py +++ b/homeassistant/components/airobot/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Airobot integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/airobot/diagnostics.py b/homeassistant/components/airobot/diagnostics.py index 972519d1fd5..6d15031cc27 100644 --- a/homeassistant/components/airobot/diagnostics.py +++ b/homeassistant/components/airobot/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Airobot.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/airobot/entity.py b/homeassistant/components/airobot/entity.py index f099952a71b..e9a33afdf7b 100644 --- a/homeassistant/components/airobot/entity.py +++ b/homeassistant/components/airobot/entity.py @@ -1,7 +1,5 @@ """Base entity for Airobot integration.""" -from __future__ import annotations - from homeassistant.const import CONF_MAC from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/airobot/models.py b/homeassistant/components/airobot/models.py index beffe5a6f95..eac948b012c 100644 --- a/homeassistant/components/airobot/models.py +++ b/homeassistant/components/airobot/models.py @@ -1,7 +1,5 @@ """Models for the Airobot integration.""" -from __future__ import annotations - from dataclasses import dataclass from pyairobotrest.models import ThermostatSettings, ThermostatStatus diff --git a/homeassistant/components/airobot/number.py b/homeassistant/components/airobot/number.py index e8d041e9489..3b22086e99b 100644 --- a/homeassistant/components/airobot/number.py +++ b/homeassistant/components/airobot/number.py @@ -1,7 +1,5 @@ """Number platform for Airobot thermostat.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass diff --git a/homeassistant/components/airobot/sensor.py b/homeassistant/components/airobot/sensor.py index 8afb78b3c76..5e08c10c008 100644 --- a/homeassistant/components/airobot/sensor.py +++ b/homeassistant/components/airobot/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Airobot thermostat.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/airobot/switch.py b/homeassistant/components/airobot/switch.py index 3a7c5d8222d..77269621daf 100644 --- a/homeassistant/components/airobot/switch.py +++ b/homeassistant/components/airobot/switch.py @@ -1,7 +1,5 @@ """Switch platform for Airobot thermostat.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/airos/__init__.py b/homeassistant/components/airos/__init__.py index a0e573f2f50..828b28bfe61 100644 --- a/homeassistant/components/airos/__init__.py +++ b/homeassistant/components/airos/__init__.py @@ -1,7 +1,5 @@ """The Ubiquiti airOS integration.""" -from __future__ import annotations - import logging from airos.airos6 import AirOS6 @@ -33,14 +31,21 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS -from .coordinator import AirOSConfigEntry, AirOSDataUpdateCoordinator +from .coordinator import ( + AirOSConfigEntry, + AirOSDataUpdateCoordinator, + AirOSFirmwareUpdateCoordinator, + AirOSRuntimeData, +) _PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, + Platform.UPDATE, ] + _LOGGER = logging.getLogger(__name__) @@ -86,10 +91,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo airos_device = airos_class(**conn_data) - coordinator = AirOSDataUpdateCoordinator(hass, entry, device_data, airos_device) - await coordinator.async_config_entry_first_refresh() + data_coordinator = AirOSDataUpdateCoordinator( + hass, entry, device_data, airos_device + ) + await data_coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + firmware_coordinator: AirOSFirmwareUpdateCoordinator | None = None + if device_data["fw_major"] >= 8: + firmware_coordinator = AirOSFirmwareUpdateCoordinator(hass, entry, airos_device) + await firmware_coordinator.async_config_entry_first_refresh() + + entry.runtime_data = AirOSRuntimeData( + status=data_coordinator, + firmware=firmware_coordinator, + ) await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) diff --git a/homeassistant/components/airos/binary_sensor.py b/homeassistant/components/airos/binary_sensor.py index 0154db8dcb5..802b66ccd4b 100644 --- a/homeassistant/components/airos/binary_sensor.py +++ b/homeassistant/components/airos/binary_sensor.py @@ -1,7 +1,5 @@ """AirOS Binary Sensor component for Home Assistant.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Generic, TypeVar @@ -87,7 +85,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the AirOS binary sensors from a config entry.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.status entities = [ AirOSBinarySensor(coordinator, description) diff --git a/homeassistant/components/airos/button.py b/homeassistant/components/airos/button.py index 44eca04b9b6..54988a6284e 100644 --- a/homeassistant/components/airos/button.py +++ b/homeassistant/components/airos/button.py @@ -1,7 +1,5 @@ """AirOS button component for Home Assistant.""" -from __future__ import annotations - from airos.exceptions import AirOSException from homeassistant.components.button import ( @@ -31,7 +29,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the AirOS button from a config entry.""" - async_add_entities([AirOSRebootButton(config_entry.runtime_data, REBOOT_BUTTON)]) + async_add_entities( + [AirOSRebootButton(config_entry.runtime_data.status, REBOOT_BUTTON)] + ) class AirOSRebootButton(AirOSEntity, ButtonEntity): diff --git a/homeassistant/components/airos/config_flow.py b/homeassistant/components/airos/config_flow.py index 4e79ba932d5..fb7f7fa768b 100644 --- a/homeassistant/components/airos/config_flow.py +++ b/homeassistant/components/airos/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Ubiquiti airOS integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging diff --git a/homeassistant/components/airos/const.py b/homeassistant/components/airos/const.py index 548c4eff805..8e268a28d54 100644 --- a/homeassistant/components/airos/const.py +++ b/homeassistant/components/airos/const.py @@ -5,6 +5,7 @@ from datetime import timedelta DOMAIN = "airos" SCAN_INTERVAL = timedelta(minutes=1) +UPDATE_SCAN_INTERVAL = timedelta(days=1) MANUFACTURER = "Ubiquiti" diff --git a/homeassistant/components/airos/coordinator.py b/homeassistant/components/airos/coordinator.py index 52ca88faebe..50e6c9810b4 100644 --- a/homeassistant/components/airos/coordinator.py +++ b/homeassistant/components/airos/coordinator.py @@ -1,8 +1,9 @@ """DataUpdateCoordinator for AirOS.""" -from __future__ import annotations - +from collections.abc import Awaitable, Callable +from dataclasses import dataclass import logging +from typing import Any, TypeVar from airos.airos6 import AirOS6, AirOS6Data from airos.airos8 import AirOS8, AirOS8Data @@ -19,20 +20,61 @@ 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, UPDATE_SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) -AirOSDeviceDetect = AirOS8 | AirOS6 -AirOSDataDetect = AirOS8Data | AirOS6Data +type AirOSDeviceDetect = AirOS8 | AirOS6 +type AirOSDataDetect = AirOS8Data | AirOS6Data +type AirOSUpdateData = dict[str, Any] -type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator] +type AirOSConfigEntry = ConfigEntry[AirOSRuntimeData] + +T = TypeVar("T", bound=AirOSDataDetect | AirOSUpdateData) + + +@dataclass +class AirOSRuntimeData: + """Data for AirOS config entry.""" + + status: AirOSDataUpdateCoordinator + firmware: AirOSFirmwareUpdateCoordinator | None + + +async def async_fetch_airos_data( + airos_device: AirOSDeviceDetect, + update_method: Callable[[], Awaitable[T]], +) -> T: + """Fetch data from AirOS device.""" + try: + await airos_device.login() + return await update_method() + except AirOSConnectionAuthenticationError as err: + _LOGGER.exception("Error authenticating with airOS device") + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from err + except ( + AirOSConnectionSetupError, + AirOSDeviceConnectionError, + TimeoutError, + ) as err: + _LOGGER.error("Error connecting to airOS device: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + except AirOSDataMissingError as err: + _LOGGER.error("Expected data not returned by airOS device: %s", err) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="error_data_missing", + ) from err class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]): - """Class to manage fetching AirOS data from single endpoint.""" + """Class to manage fetching AirOS status data from single endpoint.""" - airos_device: AirOSDeviceDetect config_entry: AirOSConfigEntry def __init__( @@ -54,28 +96,33 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]): ) async def _async_update_data(self) -> AirOSDataDetect: - """Fetch data from AirOS.""" - try: - await self.airos_device.login() - return await self.airos_device.status() - except AirOSConnectionAuthenticationError as err: - _LOGGER.exception("Error authenticating with airOS device") - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="invalid_auth" - ) from err - except ( - AirOSConnectionSetupError, - AirOSDeviceConnectionError, - TimeoutError, - ) as err: - _LOGGER.error("Error connecting to airOS device: %s", err) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from err - except AirOSDataMissingError as err: - _LOGGER.error("Expected data not returned by airOS device: %s", err) - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="error_data_missing", - ) from err + """Fetch status data from AirOS.""" + return await async_fetch_airos_data(self.airos_device, self.airos_device.status) + + +class AirOSFirmwareUpdateCoordinator(DataUpdateCoordinator[AirOSUpdateData]): + """Class to manage fetching AirOS firmware.""" + + config_entry: AirOSConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AirOSConfigEntry, + airos_device: AirOSDeviceDetect, + ) -> None: + """Initialize the coordinator.""" + self.airos_device = airos_device + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> AirOSUpdateData: + """Fetch firmware data from AirOS.""" + return await async_fetch_airos_data( + self.airos_device, self.airos_device.update_check + ) diff --git a/homeassistant/components/airos/diagnostics.py b/homeassistant/components/airos/diagnostics.py index 70fef685c86..21063afdacd 100644 --- a/homeassistant/components/airos/diagnostics.py +++ b/homeassistant/components/airos/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for airOS.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -29,5 +27,15 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" return { "entry_data": async_redact_data(entry.data, TO_REDACT_HA), - "data": async_redact_data(entry.runtime_data.data.to_dict(), TO_REDACT_AIROS), + "data": { + "status_data": async_redact_data( + entry.runtime_data.status.data.to_dict(), TO_REDACT_AIROS + ), + "firmware_data": async_redact_data( + entry.runtime_data.firmware.data + if entry.runtime_data.firmware is not None + else {}, + TO_REDACT_AIROS, + ), + }, } diff --git a/homeassistant/components/airos/entity.py b/homeassistant/components/airos/entity.py index 2a54bf2415d..c2f8b18f3bf 100644 --- a/homeassistant/components/airos/entity.py +++ b/homeassistant/components/airos/entity.py @@ -1,7 +1,5 @@ """Generic AirOS Entity Class.""" -from __future__ import annotations - from homeassistant.const import CONF_HOST, CONF_SSL from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/airos/manifest.json b/homeassistant/components/airos/manifest.json index 75d4a7d0a4a..f3bf5d829d6 100644 --- a/homeassistant/components/airos/manifest.json +++ b/homeassistant/components/airos/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["airos==0.6.4"] + "requirements": ["airos==0.6.5"] } diff --git a/homeassistant/components/airos/sensor.py b/homeassistant/components/airos/sensor.py index 8b0673e241c..622f7ffde76 100644 --- a/homeassistant/components/airos/sensor.py +++ b/homeassistant/components/airos/sensor.py @@ -1,7 +1,5 @@ """AirOS Sensor component for Home Assistant.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging @@ -180,7 +178,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the AirOS sensors from a config entry.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.status entities = [AirOSSensor(coordinator, description) for description in COMMON_SENSORS] diff --git a/homeassistant/components/airos/strings.json b/homeassistant/components/airos/strings.json index 56026eac552..fad6af5d58c 100644 --- a/homeassistant/components/airos/strings.json +++ b/homeassistant/components/airos/strings.json @@ -206,6 +206,12 @@ }, "reboot_failed": { "message": "The device did not accept the reboot request. Try again, or check your device web interface for errors." + }, + "update_connection_authentication_error": { + "message": "Authentication or connection failed during firmware update" + }, + "update_error": { + "message": "Connection failed during firmware update" } } } diff --git a/homeassistant/components/airos/update.py b/homeassistant/components/airos/update.py new file mode 100644 index 00000000000..3a595c960ed --- /dev/null +++ b/homeassistant/components/airos/update.py @@ -0,0 +1,99 @@ +"""AirOS update component for Home Assistant.""" + +import logging +from typing import Any + +from airos.exceptions import AirOSConnectionAuthenticationError, AirOSException + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import ( + AirOSConfigEntry, + AirOSDataUpdateCoordinator, + AirOSFirmwareUpdateCoordinator, +) +from .entity import AirOSEntity + +PARALLEL_UPDATES = 0 + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AirOSConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the AirOS update entity from a config entry.""" + runtime_data = config_entry.runtime_data + + if runtime_data.firmware is None: # Unsupported device + return + async_add_entities([AirOSUpdateEntity(runtime_data.status, runtime_data.firmware)]) + + +class AirOSUpdateEntity(AirOSEntity, UpdateEntity): + """Update entity for AirOS firmware updates.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = UpdateEntityFeature.INSTALL + + def __init__( + self, + status: AirOSDataUpdateCoordinator, + firmware: AirOSFirmwareUpdateCoordinator, + ) -> None: + """Initialize the AirOS update entity.""" + super().__init__(status) + self.status = status + self.firmware = firmware + + self._attr_unique_id = f"{status.data.derived.mac}_firmware_update" + + @property + def installed_version(self) -> str | None: + """Return the installed firmware version.""" + return self.status.data.host.fwversion + + @property + def latest_version(self) -> str | None: + """Return the latest firmware version.""" + if not self.firmware.data.get("update", False): + return self.status.data.host.fwversion + return self.firmware.data.get("version") + + @property + def release_url(self) -> str | None: + """Return the release url of the latest firmware.""" + return self.firmware.data.get("changelog") + + async def async_install( + self, + version: str | None, + backup: bool, + **kwargs: Any, + ) -> None: + """Handle the firmware update installation.""" + _LOGGER.debug("Starting firmware update") + try: + await self.status.airos_device.login() + await self.status.airos_device.download() + await self.status.airos_device.install() + except AirOSConnectionAuthenticationError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_connection_authentication_error", + ) from err + except AirOSException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="update_error", + ) from err diff --git a/homeassistant/components/airpatrol/__init__.py b/homeassistant/components/airpatrol/__init__.py index 6a52f18477a..5c3394145fa 100644 --- a/homeassistant/components/airpatrol/__init__.py +++ b/homeassistant/components/airpatrol/__init__.py @@ -1,7 +1,5 @@ """The AirPatrol integration.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from .const import PLATFORMS diff --git a/homeassistant/components/airpatrol/climate.py b/homeassistant/components/airpatrol/climate.py index 711c2655e98..969953d02dd 100644 --- a/homeassistant/components/airpatrol/climate.py +++ b/homeassistant/components/airpatrol/climate.py @@ -1,7 +1,5 @@ """Climate platform for AirPatrol integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.climate import ( diff --git a/homeassistant/components/airpatrol/config_flow.py b/homeassistant/components/airpatrol/config_flow.py index 7d810336676..8aea32c57bc 100644 --- a/homeassistant/components/airpatrol/config_flow.py +++ b/homeassistant/components/airpatrol/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the AirPatrol integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/airpatrol/coordinator.py b/homeassistant/components/airpatrol/coordinator.py index 37946c65a3b..f47f4de9bdd 100644 --- a/homeassistant/components/airpatrol/coordinator.py +++ b/homeassistant/components/airpatrol/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for AirPatrol.""" -from __future__ import annotations - from typing import Any from airpatrol.api import AirPatrolAPI, AirPatrolAuthenticationError, AirPatrolError diff --git a/homeassistant/components/airpatrol/entity.py b/homeassistant/components/airpatrol/entity.py index 0f4e14c0086..96b5e414435 100644 --- a/homeassistant/components/airpatrol/entity.py +++ b/homeassistant/components/airpatrol/entity.py @@ -1,7 +1,5 @@ """Base entity for AirPatrol integration.""" -from __future__ import annotations - from typing import Any from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/airpatrol/sensor.py b/homeassistant/components/airpatrol/sensor.py index f25c045599a..c5ac1d092b1 100644 --- a/homeassistant/components/airpatrol/sensor.py +++ b/homeassistant/components/airpatrol/sensor.py @@ -1,7 +1,5 @@ """Sensors for AirPatrol integration.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.sensor import ( diff --git a/homeassistant/components/airq/__init__.py b/homeassistant/components/airq/__init__.py index f87365797e7..97dd9ce9cd5 100644 --- a/homeassistant/components/airq/__init__.py +++ b/homeassistant/components/airq/__init__.py @@ -1,7 +1,5 @@ """The air-Q 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/airq/config_flow.py b/homeassistant/components/airq/config_flow.py index 734c22b0daa..cb1daabd929 100644 --- a/homeassistant/components/airq/config_flow.py +++ b/homeassistant/components/airq/config_flow.py @@ -1,7 +1,5 @@ """Config flow for air-Q integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 7c62a023a11..d00c1a1264c 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -1,7 +1,5 @@ """The air-Q integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/airq/diagnostics.py b/homeassistant/components/airq/diagnostics.py index 17299991355..8580920a86b 100644 --- a/homeassistant/components/airq/diagnostics.py +++ b/homeassistant/components/airq/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for air-Q.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/airq/number.py b/homeassistant/components/airq/number.py index e980760ed52..ed7708b4200 100644 --- a/homeassistant/components/airq/number.py +++ b/homeassistant/components/airq/number.py @@ -1,7 +1,5 @@ """Definition of air-Q number platform used to control the LED strips.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index e749ae08f33..e2d81c190a4 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -1,7 +1,5 @@ """Definition of air-Q sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging diff --git a/homeassistant/components/airthings/__init__.py b/homeassistant/components/airthings/__init__.py index 04c666dc5bc..a7bd239b543 100644 --- a/homeassistant/components/airthings/__init__.py +++ b/homeassistant/components/airthings/__init__.py @@ -1,7 +1,5 @@ """The Airthings integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/airthings/config_flow.py b/homeassistant/components/airthings/config_flow.py index 42e21b28467..bcb4e27a03a 100644 --- a/homeassistant/components/airthings/config_flow.py +++ b/homeassistant/components/airthings/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Airthings integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index 45e532268c0..7ed03cc45b8 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -1,7 +1,5 @@ """Support for Airthings sensors.""" -from __future__ import annotations - from airthings import AirthingsDevice from homeassistant.components.sensor import ( diff --git a/homeassistant/components/airthings_ble/__init__.py b/homeassistant/components/airthings_ble/__init__.py index 1c3c6084739..51d801bf25f 100644 --- a/homeassistant/components/airthings_ble/__init__.py +++ b/homeassistant/components/airthings_ble/__init__.py @@ -1,7 +1,5 @@ """The Airthings BLE integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py index a697c8e6e2c..869408237dd 100644 --- a/homeassistant/components/airthings_ble/config_flow.py +++ b/homeassistant/components/airthings_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Airthings BlE integration.""" -from __future__ import annotations - import dataclasses import logging from typing import Any diff --git a/homeassistant/components/airthings_ble/coordinator.py b/homeassistant/components/airthings_ble/coordinator.py index 74bab314876..dcecf26c7da 100644 --- a/homeassistant/components/airthings_ble/coordinator.py +++ b/homeassistant/components/airthings_ble/coordinator.py @@ -1,7 +1,5 @@ """The Airthings BLE integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index eb0d016528e..847a44a2d78 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -1,7 +1,5 @@ """Support for airthings ble sensors.""" -from __future__ import annotations - import dataclasses import logging diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py index 72b66db778f..84dd31f1bd2 100644 --- a/homeassistant/components/airtouch4/climate.py +++ b/homeassistant/components/airtouch4/climate.py @@ -1,7 +1,5 @@ """AirTouch 4 component to control of AirTouch 4 Climate Devices.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/airtouch5/__init__.py b/homeassistant/components/airtouch5/__init__.py index f0c7ba8123c..d903943d08e 100644 --- a/homeassistant/components/airtouch5/__init__.py +++ b/homeassistant/components/airtouch5/__init__.py @@ -1,7 +1,5 @@ """The Airtouch 5 integration.""" -from __future__ import annotations - from airtouch5py.airtouch5_simple_client import Airtouch5SimpleClient from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/airtouch5/config_flow.py b/homeassistant/components/airtouch5/config_flow.py index 38c85e45fb8..a191add7341 100644 --- a/homeassistant/components/airtouch5/config_flow.py +++ b/homeassistant/components/airtouch5/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Airtouch 5 integration.""" -from __future__ import annotations - import logging from typing import Any @@ -36,6 +34,8 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors = {"base": "cannot_connect"} else: + # Uses the host/IP value from CONF_HOST as unique ID, which is no longer allowed + # pylint: disable-next=hass-unique-id-ip-based await self.async_set_unique_id(user_input[CONF_HOST]) self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 9d4756cdd39..c5c37497f49 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -1,7 +1,5 @@ """The AirVisual component.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import timedelta from math import ceil diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index 129cf4b060e..64ffad7b45d 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -1,7 +1,5 @@ """Define a config flow manager for AirVisual.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/airvisual/coordinator.py b/homeassistant/components/airvisual/coordinator.py index 42c753014ce..5a8a08eb98d 100644 --- a/homeassistant/components/airvisual/coordinator.py +++ b/homeassistant/components/airvisual/coordinator.py @@ -1,7 +1,5 @@ """Define an AirVisual data coordinator.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/airvisual/diagnostics.py b/homeassistant/components/airvisual/diagnostics.py index ff4f1d919c3..35e47913060 100644 --- a/homeassistant/components/airvisual/diagnostics.py +++ b/homeassistant/components/airvisual/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for AirVisual.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/airvisual/entity.py b/homeassistant/components/airvisual/entity.py index 4bdec1d7f2e..4edc7228696 100644 --- a/homeassistant/components/airvisual/entity.py +++ b/homeassistant/components/airvisual/entity.py @@ -1,7 +1,5 @@ """The AirVisual component.""" -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/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 929fbd7c886..29337063192 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -1,7 +1,5 @@ """Support for AirVisual air quality sensors.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/airvisual_pro/__init__.py b/homeassistant/components/airvisual_pro/__init__.py index 2c56086d399..2993f3b9bbf 100644 --- a/homeassistant/components/airvisual_pro/__init__.py +++ b/homeassistant/components/airvisual_pro/__init__.py @@ -1,7 +1,5 @@ """The AirVisual Pro integration.""" -from __future__ import annotations - import asyncio from contextlib import suppress diff --git a/homeassistant/components/airvisual_pro/config_flow.py b/homeassistant/components/airvisual_pro/config_flow.py index c2d136f3102..3d1c1ab33c4 100644 --- a/homeassistant/components/airvisual_pro/config_flow.py +++ b/homeassistant/components/airvisual_pro/config_flow.py @@ -1,7 +1,5 @@ """Define a config flow manager for AirVisual Pro.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass, field from typing import Any diff --git a/homeassistant/components/airvisual_pro/coordinator.py b/homeassistant/components/airvisual_pro/coordinator.py index 946a247ace1..0b5dc58d452 100644 --- a/homeassistant/components/airvisual_pro/coordinator.py +++ b/homeassistant/components/airvisual_pro/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the AirVisual Pro integration.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/airvisual_pro/diagnostics.py b/homeassistant/components/airvisual_pro/diagnostics.py index dc69483c78f..3820e73bfe5 100644 --- a/homeassistant/components/airvisual_pro/diagnostics.py +++ b/homeassistant/components/airvisual_pro/diagnostics.py @@ -1,7 +1,5 @@ """Support for AirVisual Pro diagnostics.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/airvisual_pro/entity.py b/homeassistant/components/airvisual_pro/entity.py index b44c5ed8bce..803efc8c093 100644 --- a/homeassistant/components/airvisual_pro/entity.py +++ b/homeassistant/components/airvisual_pro/entity.py @@ -1,7 +1,5 @@ """The AirVisual Pro 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/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py index 3fac272e655..f2890b7e43e 100644 --- a/homeassistant/components/airvisual_pro/sensor.py +++ b/homeassistant/components/airvisual_pro/sensor.py @@ -1,7 +1,5 @@ """Support for AirVisual Pro sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index a56f2cc2445..af144ed8479 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -1,7 +1,5 @@ """The Airzone integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/airzone/binary_sensor.py b/homeassistant/components/airzone/binary_sensor.py index 7274df44261..6fb3df4c114 100644 --- a/homeassistant/components/airzone/binary_sensor.py +++ b/homeassistant/components/airzone/binary_sensor.py @@ -1,7 +1,5 @@ """Support for the Airzone sensors.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, Final diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 39e70e58e6d..98510b480cf 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -1,7 +1,5 @@ """Support for the Airzone climate.""" -from __future__ import annotations - from typing import Any, Final from aioairzone.common import OperationAction, OperationMode diff --git a/homeassistant/components/airzone/config_flow.py b/homeassistant/components/airzone/config_flow.py index c4088e950e9..c86cc593eaa 100644 --- a/homeassistant/components/airzone/config_flow.py +++ b/homeassistant/components/airzone/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Airzone.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/airzone/coordinator.py b/homeassistant/components/airzone/coordinator.py index 4b4519beed8..25d624726c4 100644 --- a/homeassistant/components/airzone/coordinator.py +++ b/homeassistant/components/airzone/coordinator.py @@ -1,7 +1,5 @@ """The Airzone integration.""" -from __future__ import annotations - from asyncio import timeout from datetime import timedelta import logging diff --git a/homeassistant/components/airzone/diagnostics.py b/homeassistant/components/airzone/diagnostics.py index e745a85ee5e..396f0d53da5 100644 --- a/homeassistant/components/airzone/diagnostics.py +++ b/homeassistant/components/airzone/diagnostics.py @@ -1,7 +1,5 @@ """Support for the Airzone diagnostics.""" -from __future__ import annotations - from typing import Any from aioairzone.const import API_MAC, AZD_MAC diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index 7513eec8a75..253b1c9947a 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -1,7 +1,5 @@ """Entity classes for the Airzone integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index fe259c190ff..5800e751c90 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -1,7 +1,5 @@ """Support for the Airzone sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, Final diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py index 66657836b74..52f2fad9a87 100644 --- a/homeassistant/components/airzone/sensor.py +++ b/homeassistant/components/airzone/sensor.py @@ -1,7 +1,5 @@ """Support for the Airzone sensors.""" -from __future__ import annotations - from typing import Any, Final from aioairzone.const import ( diff --git a/homeassistant/components/airzone/switch.py b/homeassistant/components/airzone/switch.py index 07278970e03..c10396b6201 100644 --- a/homeassistant/components/airzone/switch.py +++ b/homeassistant/components/airzone/switch.py @@ -1,7 +1,5 @@ """Support for the Airzone switch.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, Final diff --git a/homeassistant/components/airzone/water_heater.py b/homeassistant/components/airzone/water_heater.py index eb1537dc222..876b747911e 100644 --- a/homeassistant/components/airzone/water_heater.py +++ b/homeassistant/components/airzone/water_heater.py @@ -1,7 +1,5 @@ """Support for the Airzone water heater.""" -from __future__ import annotations - from typing import Any, Final from aioairzone.common import HotWaterOperation diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py index a5a29263140..a1da7e0216a 100644 --- a/homeassistant/components/airzone_cloud/__init__.py +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -1,7 +1,5 @@ """The Airzone Cloud integration.""" -from __future__ import annotations - from aioairzone_cloud.cloudapi import AirzoneCloudApi from aioairzone_cloud.common import ConnectionOptions diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py index 64fa8cb5151..4c9adcd9b23 100644 --- a/homeassistant/components/airzone_cloud/binary_sensor.py +++ b/homeassistant/components/airzone_cloud/binary_sensor.py @@ -1,7 +1,5 @@ """Support for the Airzone Cloud binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, Final diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index 115f6e32dbf..2c5df434efa 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -1,7 +1,5 @@ """Support for the Airzone Cloud climate.""" -from __future__ import annotations - from typing import Any, Final from aioairzone_cloud.common import OperationAction, OperationMode, TemperatureUnit diff --git a/homeassistant/components/airzone_cloud/config_flow.py b/homeassistant/components/airzone_cloud/config_flow.py index 529c710be38..bf00d6c1a21 100644 --- a/homeassistant/components/airzone_cloud/config_flow.py +++ b/homeassistant/components/airzone_cloud/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Airzone Cloud.""" -from __future__ import annotations - from typing import Any from aioairzone_cloud.cloudapi import AirzoneCloudApi diff --git a/homeassistant/components/airzone_cloud/coordinator.py b/homeassistant/components/airzone_cloud/coordinator.py index 840bfec0d1b..7f4d737a8a6 100644 --- a/homeassistant/components/airzone_cloud/coordinator.py +++ b/homeassistant/components/airzone_cloud/coordinator.py @@ -1,7 +1,5 @@ """The Airzone Cloud integration coordinator.""" -from __future__ import annotations - from asyncio import timeout from datetime import timedelta import logging diff --git a/homeassistant/components/airzone_cloud/diagnostics.py b/homeassistant/components/airzone_cloud/diagnostics.py index 04aac7e2aa8..161f2d9f9df 100644 --- a/homeassistant/components/airzone_cloud/diagnostics.py +++ b/homeassistant/components/airzone_cloud/diagnostics.py @@ -1,7 +1,5 @@ """Support for the Airzone Cloud diagnostics.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index b8ab464d20c..65f4c30d433 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -1,7 +1,5 @@ """Entity classes for the Airzone Cloud integration.""" -from __future__ import annotations - from abc import ABC, abstractmethod import logging from typing import Any diff --git a/homeassistant/components/airzone_cloud/select.py b/homeassistant/components/airzone_cloud/select.py index 816544efdf8..3a0360a1823 100644 --- a/homeassistant/components/airzone_cloud/select.py +++ b/homeassistant/components/airzone_cloud/select.py @@ -1,7 +1,5 @@ """Support for the Airzone Cloud select.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, Final diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py index 43526c3aa52..1386b4c4bc0 100644 --- a/homeassistant/components/airzone_cloud/sensor.py +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -1,7 +1,5 @@ """Support for the Airzone Cloud sensors.""" -from __future__ import annotations - from typing import Any, Final from aioairzone_cloud.const import ( diff --git a/homeassistant/components/airzone_cloud/switch.py b/homeassistant/components/airzone_cloud/switch.py index ab703cd537a..9b1470f3541 100644 --- a/homeassistant/components/airzone_cloud/switch.py +++ b/homeassistant/components/airzone_cloud/switch.py @@ -1,7 +1,5 @@ """Support for the Airzone Cloud switch.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, Final diff --git a/homeassistant/components/airzone_cloud/water_heater.py b/homeassistant/components/airzone_cloud/water_heater.py index 41d43002569..54d8deeaaa7 100644 --- a/homeassistant/components/airzone_cloud/water_heater.py +++ b/homeassistant/components/airzone_cloud/water_heater.py @@ -1,7 +1,5 @@ """Support for the Airzone Cloud water heater.""" -from __future__ import annotations - from typing import Any, Final from aioairzone_cloud.common import HotWaterOperation, TemperatureUnit diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 5d82c8df682..2ea8ceef834 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -1,7 +1,5 @@ """The Aladdin Connect Genie integration.""" -from __future__ import annotations - import aiohttp from genie_partner_sdk.client import AladdinConnectClient diff --git a/homeassistant/components/aladdin_connect/coordinator.py b/homeassistant/components/aladdin_connect/coordinator.py index c18ef8e0bbf..337f9840b74 100644 --- a/homeassistant/components/aladdin_connect/coordinator.py +++ b/homeassistant/components/aladdin_connect/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Aladdin Connect integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index e6c5d0457b5..2b5596598bf 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -1,7 +1,5 @@ """Cover Entity for Genie Garage Door.""" -from __future__ import annotations - from typing import Any import aiohttp diff --git a/homeassistant/components/aladdin_connect/diagnostics.py b/homeassistant/components/aladdin_connect/diagnostics.py index 583141bbca0..a57235533a9 100644 --- a/homeassistant/components/aladdin_connect/diagnostics.py +++ b/homeassistant/components/aladdin_connect/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Aladdin Connect.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/aladdin_connect/sensor.py b/homeassistant/components/aladdin_connect/sensor.py index 45943327ad4..1655712b0f7 100644 --- a/homeassistant/components/aladdin_connect/sensor.py +++ b/homeassistant/components/aladdin_connect/sensor.py @@ -1,7 +1,5 @@ """Support for Aladdin Connect Genie sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index d54e9bbdf77..b9c75f07f45 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -1,7 +1,5 @@ """Component to interface with an alarm control panel.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any, Final, final diff --git a/homeassistant/components/alarm_control_panel/conditions.yaml b/homeassistant/components/alarm_control_panel/conditions.yaml index 12c5b700b32..ae4c27cf6eb 100644 --- a/homeassistant/components/alarm_control_panel/conditions.yaml +++ b/homeassistant/components/alarm_control_panel/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_armed: *condition_common diff --git a/homeassistant/components/alarm_control_panel/device_action.py b/homeassistant/components/alarm_control_panel/device_action.py index 6779eada070..e996a42c1f2 100644 --- a/homeassistant/components/alarm_control_panel/device_action.py +++ b/homeassistant/components/alarm_control_panel/device_action.py @@ -1,7 +1,5 @@ """Provides device automations for Alarm control panel.""" -from __future__ import annotations - from typing import Final import voluptuous as vol diff --git a/homeassistant/components/alarm_control_panel/device_condition.py b/homeassistant/components/alarm_control_panel/device_condition.py index 6d343bbe605..fece5f4d30f 100644 --- a/homeassistant/components/alarm_control_panel/device_condition.py +++ b/homeassistant/components/alarm_control_panel/device_condition.py @@ -1,7 +1,5 @@ """Provide the device automations for Alarm control panel.""" -from __future__ import annotations - from typing import Final import voluptuous as vol diff --git a/homeassistant/components/alarm_control_panel/device_trigger.py b/homeassistant/components/alarm_control_panel/device_trigger.py index a488cf10870..b1265fcc224 100644 --- a/homeassistant/components/alarm_control_panel/device_trigger.py +++ b/homeassistant/components/alarm_control_panel/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Alarm control panel.""" -from __future__ import annotations - from typing import Final import voluptuous as vol diff --git a/homeassistant/components/alarm_control_panel/reproduce_state.py b/homeassistant/components/alarm_control_panel/reproduce_state.py index 765514e98ec..80fdc2049c9 100644 --- a/homeassistant/components/alarm_control_panel/reproduce_state.py +++ b/homeassistant/components/alarm_control_panel/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Alarm control panel state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/alarm_control_panel/significant_change.py b/homeassistant/components/alarm_control_panel/significant_change.py index 4a2209e0868..5564459ce7c 100644 --- a/homeassistant/components/alarm_control_panel/significant_change.py +++ b/homeassistant/components/alarm_control_panel/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Alarm Control Panel state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 63c907d25c6..59dde5f8869 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted alarms.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted alarms to trigger on.", - "trigger_behavior_name": "Behavior" + "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_armed": { "description": "Tests if one or more alarms are armed.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::condition_for_name%]" } }, "name": "Alarm is armed" @@ -20,8 +22,10 @@ "description": "Tests if one or more alarms are armed in away mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::condition_for_name%]" } }, "name": "Alarm is armed away" @@ -30,8 +34,10 @@ "description": "Tests if one or more alarms are armed in home mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::condition_for_name%]" } }, "name": "Alarm is armed home" @@ -40,8 +46,10 @@ "description": "Tests if one or more alarms are armed in night mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::condition_for_name%]" } }, "name": "Alarm is armed night" @@ -50,8 +58,10 @@ "description": "Tests if one or more alarms are armed in vacation mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::condition_for_name%]" } }, "name": "Alarm is armed vacation" @@ -60,8 +70,10 @@ "description": "Tests if one or more alarms are disarmed.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::condition_for_name%]" } }, "name": "Alarm is disarmed" @@ -70,8 +82,10 @@ "description": "Tests if one or more alarms are triggered.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::condition_for_name%]" } }, "name": "Alarm is triggered" @@ -149,21 +163,6 @@ "message": "Arming requires a code but none was given for {entity_id}." } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "alarm_arm_away": { "description": "Arms an alarm in the away mode.", @@ -242,8 +241,10 @@ "description": "Triggers after one or more alarms become armed, regardless of the mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::trigger_for_name%]" } }, "name": "Alarm armed" @@ -252,8 +253,10 @@ "description": "Triggers after one or more alarms become armed in away mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::trigger_for_name%]" } }, "name": "Alarm armed away" @@ -262,8 +265,10 @@ "description": "Triggers after one or more alarms become armed in home mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::trigger_for_name%]" } }, "name": "Alarm armed home" @@ -272,8 +277,10 @@ "description": "Triggers after one or more alarms become armed in night mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::trigger_for_name%]" } }, "name": "Alarm armed night" @@ -282,8 +289,10 @@ "description": "Triggers after one or more alarms become armed in vacation mode.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::trigger_for_name%]" } }, "name": "Alarm armed vacation" @@ -292,8 +301,10 @@ "description": "Triggers after one or more alarms become disarmed.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::trigger_for_name%]" } }, "name": "Alarm disarmed" @@ -302,8 +313,10 @@ "description": "Triggers after one or more alarms become triggered.", "fields": { "behavior": { - "description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]", "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::trigger_for_name%]" } }, "name": "Alarm triggered" diff --git a/homeassistant/components/alarm_control_panel/triggers.yaml b/homeassistant/components/alarm_control_panel/triggers.yaml index 49723026f87..d5045e79762 100644 --- a/homeassistant/components/alarm_control_panel/triggers.yaml +++ b/homeassistant/components/alarm_control_panel/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: armed: *trigger_common diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index ea3f339256a..f6f5f96fcb9 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for AlarmDecoder-based alarm control panels (Honeywell/DSC).""" -from __future__ import annotations - from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, diff --git a/homeassistant/components/alarmdecoder/config_flow.py b/homeassistant/components/alarmdecoder/config_flow.py index 093ed220973..9aefec80457 100644 --- a/homeassistant/components/alarmdecoder/config_flow.py +++ b/homeassistant/components/alarmdecoder/config_flow.py @@ -1,12 +1,10 @@ """Config flow for AlarmDecoder.""" -from __future__ import annotations - import logging -from typing import Any +from typing import Any, cast from adext import AdExt -from alarmdecoder.devices import SerialDevice, SocketDevice +from alarmdecoder.devices import Device, SerialDevice, SocketDevice from alarmdecoder.util import NoDeviceError import voluptuous as vol @@ -102,16 +100,21 @@ class AlarmDecoderFlowHandler(ConfigFlow, domain=DOMAIN): self._async_current_entries(), user_input, self.protocol ): return self.async_abort(reason="already_configured") - connection = {} + connection: dict[str, Any] = {} baud = None + device: Device if self.protocol == PROTOCOL_SOCKET: - host = connection[CONF_HOST] = user_input[CONF_HOST] - port = connection[CONF_PORT] = user_input[CONF_PORT] - title = f"{host}:{port}" + host = connection[CONF_HOST] = cast(str, user_input[CONF_HOST]) + port = connection[CONF_PORT] = cast(int, user_input[CONF_PORT]) + title: str = f"{host}:{port}" device = SocketDevice(interface=(host, port)) if self.protocol == PROTOCOL_SERIAL: - path = connection[CONF_DEVICE_PATH] = user_input[CONF_DEVICE_PATH] - baud = connection[CONF_DEVICE_BAUD] = user_input[CONF_DEVICE_BAUD] + path = connection[CONF_DEVICE_PATH] = cast( + str, user_input[CONF_DEVICE_PATH] + ) + baud = connection[CONF_DEVICE_BAUD] = cast( + int, user_input[CONF_DEVICE_BAUD] + ) title = path device = SerialDevice(interface=path) @@ -132,6 +135,7 @@ class AlarmDecoderFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception during AlarmDecoder setup") errors["base"] = "unknown" + schema: vol.Schema if self.protocol == PROTOCOL_SOCKET: schema = vol.Schema( { diff --git a/homeassistant/components/alarmdecoder/services.py b/homeassistant/components/alarmdecoder/services.py index d9d5002ca94..cff0684bd76 100644 --- a/homeassistant/components/alarmdecoder/services.py +++ b/homeassistant/components/alarmdecoder/services.py @@ -1,7 +1,5 @@ """Support for AlarmDecoder-based alarm control panels (Honeywell/DSC).""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.alarm_control_panel import ( diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 8be19850881..33e5f1dd234 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -3,8 +3,6 @@ DEVELOPMENT OF THE ALERT INTEGRATION IS FROZEN. """ -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ( diff --git a/homeassistant/components/alert/entity.py b/homeassistant/components/alert/entity.py index a7f9f50f61e..8419043bf5d 100644 --- a/homeassistant/components/alert/entity.py +++ b/homeassistant/components/alert/entity.py @@ -3,8 +3,6 @@ DEVELOPMENT OF THE ALERT INTEGRATION IS FROZEN. """ -from __future__ import annotations - from collections.abc import Callable from datetime import timedelta from typing import Any diff --git a/homeassistant/components/alert/reproduce_state.py b/homeassistant/components/alert/reproduce_state.py index dee20bc1c5d..c7a908a3c5e 100644 --- a/homeassistant/components/alert/reproduce_state.py +++ b/homeassistant/components/alert/reproduce_state.py @@ -3,8 +3,6 @@ DEVELOPMENT OF THE ALERT INTEGRATION IS FROZEN. """ -from __future__ import annotations - import asyncio from collections.abc import Iterable from typing import Any diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index eeeb8e53e43..7347b3419a9 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -1,7 +1,5 @@ """Support for Alexa skill service end point.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 90f09f343ec..e002e78819e 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1,7 +1,5 @@ """Alexa capabilities.""" -from __future__ import annotations - from collections.abc import Generator import logging from typing import Any diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index 0801a32a607..87b97f7f0dc 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -1,7 +1,5 @@ """Config helpers for Alexa.""" -from __future__ import annotations - from abc import ABC, abstractmethod import asyncio import logging diff --git a/homeassistant/components/alexa/diagnostics.py b/homeassistant/components/alexa/diagnostics.py index 54233a0f432..04c003e5250 100644 --- a/homeassistant/components/alexa/diagnostics.py +++ b/homeassistant/components/alexa/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics helpers for Alexa.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 5f789813869..e58b3b80ffc 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -1,7 +1,5 @@ """Alexa entity adapters.""" -from __future__ import annotations - from collections.abc import Generator, Iterable import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index c341356db86..120b165b68e 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -1,7 +1,5 @@ """Alexa related errors.""" -from __future__ import annotations - from typing import Any, Literal from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 793c3680724..2186ce0234c 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1,7 +1,5 @@ """Alexa message handlers.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine import logging diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 7c87e5418e7..82d6e9b302b 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -1,7 +1,5 @@ """Alexa state report code.""" -from __future__ import annotations - from asyncio import timeout from collections.abc import Mapping from http import HTTPStatus diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index af0a3d7818c..4e510476969 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -11,6 +11,7 @@ from .services import async_setup_services PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py index 0e44416aff7..09bcaad022a 100644 --- a/homeassistant/components/alexa_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -1,7 +1,5 @@ """Support for binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Final diff --git a/homeassistant/components/alexa_devices/button.py b/homeassistant/components/alexa_devices/button.py new file mode 100644 index 00000000000..9a735f550fc --- /dev/null +++ b/homeassistant/components/alexa_devices/button.py @@ -0,0 +1,55 @@ +"""Support for buttons.""" + +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import slugify + +from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator +from .entity import AmazonServiceEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AmazonConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up button entities for Alexa Devices.""" + coordinator = entry.runtime_data + + known_routines: set[str] = set() + + def _check_routines() -> None: + current_routines = set(coordinator.api.routines) + new_routines = current_routines - known_routines + if new_routines: + known_routines.update(new_routines) + async_add_entities( + AmazonRoutineButton(coordinator, routine) for routine in new_routines + ) + + _check_routines() + entry.async_on_unload(coordinator.async_add_listener(_check_routines)) + + +class AmazonRoutineButton(AmazonServiceEntity, ButtonEntity): + """Button entity for Alexa routine.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: AmazonDevicesCoordinator, routine: str) -> None: + """Initialize the routine button entity.""" + self._coordinator = coordinator + self._routine = routine + super().__init__( + coordinator, + EntityDescription(key=slugify(routine), name=routine), + ) + + async def async_press(self) -> None: + """Handle button press action.""" + await self._coordinator.api.call_routine(self._routine) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index cc7cdc3eb07..66f1f55192a 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Alexa Devices integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 87299e647fe..a5414722baa 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -12,12 +12,13 @@ from aioamazondevices.structures import AmazonDevice from aiohttp import ClientSession from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import slugify from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN @@ -54,7 +55,23 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): entry.data[CONF_PASSWORD], entry.data[CONF_LOGIN_DATA], ) - self.previous_devices: set[str] = set() + device_registry = dr.async_get(hass) + self.previous_devices: set[str] = { + identifier + for device in device_registry.devices.get_devices_for_config_entry_id( + entry.entry_id + ) + if device.entry_type != dr.DeviceEntryType.SERVICE + for identifier_domain, identifier in device.identifiers + if identifier_domain == DOMAIN + } + self.previous_routines: set[str] = { + routine.unique_id + for routine in er.async_entries_for_config_entry( + er.async_get(hass), entry.entry_id + ) + if routine.domain == Platform.BUTTON + } async def _async_update_data(self) -> dict[str, AmazonDevice]: """Update device data.""" @@ -83,8 +100,13 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): current_devices = set(data.keys()) if stale_devices := self.previous_devices - current_devices: await self._async_remove_device_stale(stale_devices) - self.previous_devices = current_devices + + current_routines = {slugify(routine) for routine in self.api.routines} + if stale_routines := self.previous_routines - current_routines: + await self._async_remove_routine_stale(stale_routines) + self.previous_routines = current_routines + return data async def _async_remove_device_stale( @@ -107,3 +129,23 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): device_id=device.id, remove_config_entry_id=self.config_entry.entry_id, ) + + async def _async_remove_routine_stale( + self, + stale_routines: set[str], + ) -> None: + """Remove stale routine.""" + entity_registry = er.async_get(self.hass) + + for routine in stale_routines: + _LOGGER.debug( + "Detected change in routines: routine %s removed", + routine, + ) + entity_id = entity_registry.async_get_entity_id( + Platform.BUTTON, + DOMAIN, + f"{slugify(self.config_entry.unique_id)}-{slugify(routine)}", + ) + if entity_id: + entity_registry.async_remove(entity_id) diff --git a/homeassistant/components/alexa_devices/diagnostics.py b/homeassistant/components/alexa_devices/diagnostics.py index cf08e0dfd6e..0cc8f201e8a 100644 --- a/homeassistant/components/alexa_devices/diagnostics.py +++ b/homeassistant/components/alexa_devices/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Alexa Devices integration.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/alexa_devices/entity.py b/homeassistant/components/alexa_devices/entity.py index 21b01e26f6c..57a67d9d31f 100644 --- a/homeassistant/components/alexa_devices/entity.py +++ b/homeassistant/components/alexa_devices/entity.py @@ -2,9 +2,10 @@ from aioamazondevices.structures import AmazonDevice -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import slugify from .const import DOMAIN from .coordinator import AmazonDevicesCoordinator @@ -50,3 +51,32 @@ class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]): and self._serial_num in self.coordinator.data and self.device.online ) + + +class AmazonServiceEntity(CoordinatorEntity[AmazonDevicesCoordinator]): + """Defines Alexa Devices entity for service device.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AmazonDevicesCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the service entity.""" + + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_device_id(coordinator))}, + manufacturer="Amazon", + entry_type=DeviceEntryType.SERVICE, + ) + self.entity_description = description + self._attr_unique_id = ( + f"{slugify(coordinator.config_entry.unique_id)}-{description.key}" + ) + + +def service_device_id(coordinator: AmazonDevicesCoordinator) -> str: + """Return service device id.""" + return slugify(f"{coordinator.config_entry.unique_id}_service_device") diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 0401bb3828e..a6210eb432b 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==13.3.1"] + "requirements": ["aioamazondevices==13.4.3"] } diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py index 99a0a12d4f5..c810275afa3 100644 --- a/homeassistant/components/alexa_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -1,7 +1,5 @@ """Support for notification entity.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any, Final diff --git a/homeassistant/components/alexa_devices/sensor.py b/homeassistant/components/alexa_devices/sensor.py index 209f9636405..4a096c2351e 100644 --- a/homeassistant/components/alexa_devices/sensor.py +++ b/homeassistant/components/alexa_devices/sensor.py @@ -1,7 +1,5 @@ """Support for sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index 7c033834b0d..274daacfcf6 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -1,7 +1,5 @@ """Support for switches.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py index 48d3ae6f526..c9d87b1edd2 100644 --- a/homeassistant/components/alpha_vantage/sensor.py +++ b/homeassistant/components/alpha_vantage/sensor.py @@ -1,7 +1,5 @@ """Stock market information from Alpha Vantage.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/altruist/__init__.py b/homeassistant/components/altruist/__init__.py index 6040b347bb5..3a8faae7532 100644 --- a/homeassistant/components/altruist/__init__.py +++ b/homeassistant/components/altruist/__init__.py @@ -1,7 +1,5 @@ """The Altruist integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/amazon_polly/const.py b/homeassistant/components/amazon_polly/const.py index 40b1bba3ddd..350dee1aa59 100644 --- a/homeassistant/components/amazon_polly/const.py +++ b/homeassistant/components/amazon_polly/const.py @@ -1,7 +1,5 @@ """Constants for the Amazon Polly text to speech service.""" -from __future__ import annotations - from typing import Final CONF_REGION: Final = "region_name" diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index 985b3b6dd7c..ac4c68c05f0 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -1,7 +1,5 @@ """Support for the Amazon Polly text to speech service.""" -from __future__ import annotations - from collections import defaultdict import logging from typing import Any, Final diff --git a/homeassistant/components/amberelectric/binary_sensor.py b/homeassistant/components/amberelectric/binary_sensor.py index 3ee27f19849..048bd3a12de 100644 --- a/homeassistant/components/amberelectric/binary_sensor.py +++ b/homeassistant/components/amberelectric/binary_sensor.py @@ -1,7 +1,5 @@ """Amber Electric Binary Sensor definitions.""" -from __future__ import annotations - from typing import Any from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/amberelectric/config_flow.py b/homeassistant/components/amberelectric/config_flow.py index b5f034b4448..0985df787e4 100644 --- a/homeassistant/components/amberelectric/config_flow.py +++ b/homeassistant/components/amberelectric/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Amber Electric integration.""" -from __future__ import annotations - import amberelectric from amberelectric.models.site import Site from amberelectric.models.site_status import SiteStatus diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py index 2ea14b5200b..f5c7950df99 100644 --- a/homeassistant/components/amberelectric/coordinator.py +++ b/homeassistant/components/amberelectric/coordinator.py @@ -1,7 +1,5 @@ """Amber Electric Coordinator.""" -from __future__ import annotations - from datetime import timedelta from typing import Any diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index f7a61bea5a5..ad673d9e306 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -4,8 +4,6 @@ # Current and forecast will create general, controlled load and feed in as required # At the moment renewables in the only grid sensor. -from __future__ import annotations - from typing import Any from amberelectric.models.channel import ChannelType diff --git a/homeassistant/components/ambient_network/__init__.py b/homeassistant/components/ambient_network/__init__.py index 5c39982eb91..2b1064c76dc 100644 --- a/homeassistant/components/ambient_network/__init__.py +++ b/homeassistant/components/ambient_network/__init__.py @@ -1,7 +1,5 @@ """The Ambient Weather Network integration.""" -from __future__ import annotations - from aioambient.open_api import OpenAPI from homeassistant.const import Platform diff --git a/homeassistant/components/ambient_network/config_flow.py b/homeassistant/components/ambient_network/config_flow.py index d195db03149..abb59c61345 100644 --- a/homeassistant/components/ambient_network/config_flow.py +++ b/homeassistant/components/ambient_network/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Ambient Weather Network integration.""" -from __future__ import annotations - from typing import Any from aioambient import OpenAPI diff --git a/homeassistant/components/ambient_network/coordinator.py b/homeassistant/components/ambient_network/coordinator.py index 5fb1939f6b4..adb9a40573c 100644 --- a/homeassistant/components/ambient_network/coordinator.py +++ b/homeassistant/components/ambient_network/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Ambient Weather Network integration.""" -from __future__ import annotations - from datetime import datetime, timedelta from typing import Any, cast diff --git a/homeassistant/components/ambient_network/entity.py b/homeassistant/components/ambient_network/entity.py index ad0241ea3de..38e59ab02b5 100644 --- a/homeassistant/components/ambient_network/entity.py +++ b/homeassistant/components/ambient_network/entity.py @@ -1,7 +1,5 @@ """Base entity class for the Ambient Weather Network integration.""" -from __future__ import annotations - from abc import abstractmethod from homeassistant.core import callback diff --git a/homeassistant/components/ambient_network/helper.py b/homeassistant/components/ambient_network/helper.py index fbde45ee756..962f15fdf72 100644 --- a/homeassistant/components/ambient_network/helper.py +++ b/homeassistant/components/ambient_network/helper.py @@ -1,7 +1,5 @@ """Helper class for the Ambient Weather Network integration.""" -from __future__ import annotations - from typing import Any from .const import ( diff --git a/homeassistant/components/ambient_network/sensor.py b/homeassistant/components/ambient_network/sensor.py index 03476f6b8a0..249cc0cb2e8 100644 --- a/homeassistant/components/ambient_network/sensor.py +++ b/homeassistant/components/ambient_network/sensor.py @@ -1,7 +1,5 @@ """Support for Ambient Weather Network sensors.""" -from __future__ import annotations - from datetime import datetime from homeassistant.components.sensor import ( diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 374c313a144..953743c66a6 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -1,7 +1,5 @@ """Support for Ambient Weather Station Service.""" -from __future__ import annotations - from typing import Any from aioambient import Websocket diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py index 9a7c89db95e..eb0a0fce3c7 100644 --- a/homeassistant/components/ambient_station/binary_sensor.py +++ b/homeassistant/components/ambient_station/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Ambient Weather Station binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Literal diff --git a/homeassistant/components/ambient_station/config_flow.py b/homeassistant/components/ambient_station/config_flow.py index 1c76008f040..a3b3c42af24 100644 --- a/homeassistant/components/ambient_station/config_flow.py +++ b/homeassistant/components/ambient_station/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Ambient PWS component.""" -from __future__ import annotations - from typing import Any from aioambient import API diff --git a/homeassistant/components/ambient_station/diagnostics.py b/homeassistant/components/ambient_station/diagnostics.py index bddbb1ab9df..70ddb57f8eb 100644 --- a/homeassistant/components/ambient_station/diagnostics.py +++ b/homeassistant/components/ambient_station/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Ambient PWS.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/ambient_station/entity.py b/homeassistant/components/ambient_station/entity.py index 9dec905b157..6c6c39be870 100644 --- a/homeassistant/components/ambient_station/entity.py +++ b/homeassistant/components/ambient_station/entity.py @@ -1,7 +1,5 @@ """Base entity Ambient Weather Station Service.""" -from __future__ import annotations - from aioambient.util import get_public_device_id from homeassistant.core import callback diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 9e034159724..e60f431a0bc 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -1,7 +1,5 @@ """Support for Ambient Weather Station sensors.""" -from __future__ import annotations - from datetime import UTC, datetime from homeassistant.components.sensor import ( diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 241256fb5ca..bd1b112b103 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -1,7 +1,5 @@ """Support for Amcrest IP cameras.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncGenerator, Callable from contextlib import asynccontextmanager, suppress @@ -39,7 +37,6 @@ from homeassistant.helpers.typing import ConfigType from .binary_sensor import BINARY_SENSOR_KEYS, BINARY_SENSORS, check_binary_sensors from .camera import STREAM_SOURCE_LIST from .const import ( - CAMERAS, COMM_RETRIES, COMM_TIMEOUT, DATA_AMCREST, @@ -359,7 +356,7 @@ def _start_event_monitor( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Amcrest IP Camera component.""" - hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []}) + hass.data.setdefault(DATA_AMCREST, {DEVICES: {}}) for device in config[DOMAIN]: name: str = device[CONF_NAME] diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index ccbf5efd8f4..fef666ea89a 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Amcrest IP camera binary sensors.""" -from __future__ import annotations - from contextlib import suppress from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 5c3655e8d31..647a79b5eba 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -1,7 +1,5 @@ """Support for Amcrest IP cameras.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from datetime import timedelta @@ -12,13 +10,11 @@ import aiohttp from aiohttp import web from amcrest import AmcrestError from haffmpeg.camera import CameraMjpeg -import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.ffmpeg import FFmpegManager, get_ffmpeg_manager -from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON +from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_stream, async_aiohttp_proxy_web, @@ -29,11 +25,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( + ATTR_COLOR_BW, CAMERA_WEB_SESSION_TIMEOUT, - CAMERAS, + CBW, COMM_TIMEOUT, DATA_AMCREST, DEVICES, + MOV, RESOLUTION_TO_STREAM, SERVICE_UPDATE, SNAPSHOT_TIMEOUT, @@ -49,65 +47,11 @@ SCAN_INTERVAL = timedelta(seconds=15) STREAM_SOURCE_LIST = ["snapshot", "mjpeg", "rtsp"] -_ATTR_PTZ_TT = "travel_time" -_ATTR_PTZ_MOV = "movement" -_MOV = [ - "zoom_out", - "zoom_in", - "right", - "left", - "up", - "down", - "right_down", - "right_up", - "left_down", - "left_up", -] _ZOOM_ACTIONS = ["ZoomWide", "ZoomTele"] _MOVE_1_ACTIONS = ["Right", "Left", "Up", "Down"] _MOVE_2_ACTIONS = ["RightDown", "RightUp", "LeftDown", "LeftUp"] _ACTION = _ZOOM_ACTIONS + _MOVE_1_ACTIONS + _MOVE_2_ACTIONS -_DEFAULT_TT = 0.2 - -_ATTR_PRESET = "preset" -_ATTR_COLOR_BW = "color_bw" - -_CBW_COLOR = "color" -_CBW_AUTO = "auto" -_CBW_BW = "bw" -_CBW = [_CBW_COLOR, _CBW_AUTO, _CBW_BW] - -_SRV_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids}) -_SRV_GOTO_SCHEMA = _SRV_SCHEMA.extend( - {vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1))} -) -_SRV_CBW_SCHEMA = _SRV_SCHEMA.extend({vol.Required(_ATTR_COLOR_BW): vol.In(_CBW)}) -_SRV_PTZ_SCHEMA = _SRV_SCHEMA.extend( - { - vol.Required(_ATTR_PTZ_MOV): vol.In(_MOV), - vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float, - } -) - -CAMERA_SERVICES = { - "enable_recording": (_SRV_SCHEMA, "async_enable_recording", ()), - "disable_recording": (_SRV_SCHEMA, "async_disable_recording", ()), - "enable_audio": (_SRV_SCHEMA, "async_enable_audio", ()), - "disable_audio": (_SRV_SCHEMA, "async_disable_audio", ()), - "enable_motion_recording": (_SRV_SCHEMA, "async_enable_motion_recording", ()), - "disable_motion_recording": (_SRV_SCHEMA, "async_disable_motion_recording", ()), - "goto_preset": (_SRV_GOTO_SCHEMA, "async_goto_preset", (_ATTR_PRESET,)), - "set_color_bw": (_SRV_CBW_SCHEMA, "async_set_color_bw", (_ATTR_COLOR_BW,)), - "start_tour": (_SRV_SCHEMA, "async_start_tour", ()), - "stop_tour": (_SRV_SCHEMA, "async_stop_tour", ()), - "ptz_control": ( - _SRV_PTZ_SCHEMA, - "async_ptz_control", - (_ATTR_PTZ_MOV, _ATTR_PTZ_TT), - ), -} - _BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF} @@ -275,7 +219,7 @@ class AmcrestCam(Camera): self._motion_recording_enabled ) if self._color_bw is not None: - attr[_ATTR_COLOR_BW] = self._color_bw + attr[ATTR_COLOR_BW] = self._color_bw return attr @property @@ -322,15 +266,7 @@ class AmcrestCam(Camera): self.async_schedule_update_ha_state(True) async def async_added_to_hass(self) -> None: - """Subscribe to signals and add camera to list.""" - self._unsub_dispatcher.extend( - async_dispatcher_connect( - self.hass, - service_signal(service, self.entity_id), - getattr(self, callback_name), - ) - for service, (_, callback_name, _) in CAMERA_SERVICES.items() - ) + """Subscribe to signals.""" self._unsub_dispatcher.append( async_dispatcher_connect( self.hass, @@ -338,11 +274,9 @@ class AmcrestCam(Camera): self.async_on_demand_update, ) ) - self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id) async def async_will_remove_from_hass(self) -> None: - """Remove camera from list and disconnect from signals.""" - self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id) + """Disconnect from signals.""" for unsub_dispatcher in self._unsub_dispatcher: unsub_dispatcher() @@ -456,7 +390,7 @@ class AmcrestCam(Camera): async def async_ptz_control(self, movement: str, travel_time: float) -> None: """Move or zoom camera in specified direction.""" - code = _ACTION[_MOV.index(movement)] + code = _ACTION[MOV.index(movement)] kwargs = {"code": code, "arg1": 0, "arg2": 0, "arg3": 0} if code in _MOVE_1_ACTIONS: @@ -613,10 +547,10 @@ class AmcrestCam(Camera): ) async def _async_get_color_mode(self) -> str: - return _CBW[await self._api.async_day_night_color] + return CBW[await self._api.async_day_night_color] async def _async_set_color_mode(self, cbw: str) -> None: - await self._api.async_set_day_night_color(_CBW.index(cbw), channel=0) + await self._api.async_set_day_night_color(CBW.index(cbw), channel=0) async def _async_set_color_bw(self, cbw: str) -> None: """Set camera color mode.""" diff --git a/homeassistant/components/amcrest/const.py b/homeassistant/components/amcrest/const.py index 377c5642b4b..67f37a826a2 100644 --- a/homeassistant/components/amcrest/const.py +++ b/homeassistant/components/amcrest/const.py @@ -2,7 +2,6 @@ DOMAIN = "amcrest" DATA_AMCREST = DOMAIN -CAMERAS = "cameras" DEVICES = "devices" BINARY_SENSOR_SCAN_INTERVAL_SECS = 5 @@ -17,3 +16,18 @@ SERVICE_UPDATE = "update" RESOLUTION_LIST = {"high": 0, "low": 1} RESOLUTION_TO_STREAM = {0: "Main", 1: "Extra"} + +ATTR_COLOR_BW = "color_bw" +CBW = ["color", "auto", "bw"] +MOV = [ + "zoom_out", + "zoom_in", + "right", + "left", + "up", + "down", + "right_down", + "right_up", + "left_down", + "left_up", +] diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py index 5da1ea412bf..33fa499ab37 100644 --- a/homeassistant/components/amcrest/helpers.py +++ b/homeassistant/components/amcrest/helpers.py @@ -1,7 +1,5 @@ """Helpers for amcrest component.""" -from __future__ import annotations - import logging from homeassistant.helpers.typing import UndefinedType diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index b54d71c5814..fc1c1c07224 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -1,7 +1,5 @@ """Support for Amcrest IP camera sensors.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/amcrest/services.py b/homeassistant/components/amcrest/services.py index 6b4ca8ade53..187f3f299cc 100644 --- a/homeassistant/components/amcrest/services.py +++ b/homeassistant/components/amcrest/services.py @@ -1,62 +1,65 @@ -"""Support for Amcrest IP cameras.""" +"""Services for Amcrest IP cameras.""" -from __future__ import annotations +import voluptuous as vol -from homeassistant.auth.models import User -from homeassistant.auth.permissions.const import POLICY_CONTROL -from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import Unauthorized, UnknownUser -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.service import async_extract_entity_ids +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service -from .camera import CAMERA_SERVICES -from .const import CAMERAS, DATA_AMCREST, DOMAIN -from .helpers import service_signal +from .const import ATTR_COLOR_BW, CBW, DOMAIN, MOV + +_ATTR_PRESET = "preset" +_ATTR_PTZ_MOV = "movement" +_ATTR_PTZ_TT = "travel_time" +_DEFAULT_TT = 0.2 @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the Amcrest IP Camera services.""" + for service_name, func in ( + ("enable_recording", "async_enable_recording"), + ("disable_recording", "async_disable_recording"), + ("enable_audio", "async_enable_audio"), + ("disable_audio", "async_disable_audio"), + ("enable_motion_recording", "async_enable_motion_recording"), + ("disable_motion_recording", "async_disable_motion_recording"), + ("start_tour", "async_start_tour"), + ("stop_tour", "async_stop_tour"), + ): + service.async_register_platform_entity_service( + hass, + DOMAIN, + service_name, + entity_domain=CAMERA_DOMAIN, + schema=None, + func=func, + ) - def have_permission(user: User | None, entity_id: str) -> bool: - return not user or user.permissions.check_entity(entity_id, POLICY_CONTROL) - - async def async_extract_from_service(call: ServiceCall) -> list[str]: - if call.context.user_id: - user = await hass.auth.async_get_user(call.context.user_id) - if user is None: - raise UnknownUser(context=call.context) - else: - user = None - - if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL: - # Return all entity_ids user has permission to control. - return [ - entity_id - for entity_id in hass.data[DATA_AMCREST][CAMERAS] - if have_permission(user, entity_id) - ] - - if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_NONE: - return [] - - call_ids = await async_extract_entity_ids(call) - entity_ids = [] - for entity_id in hass.data[DATA_AMCREST][CAMERAS]: - if entity_id not in call_ids: - continue - if not have_permission(user, entity_id): - raise Unauthorized( - context=call.context, entity_id=entity_id, permission=POLICY_CONTROL - ) - entity_ids.append(entity_id) - return entity_ids - - async def async_service_handler(call: ServiceCall) -> None: - args = [call.data[arg] for arg in CAMERA_SERVICES[call.service][2]] - for entity_id in await async_extract_from_service(call): - async_dispatcher_send(hass, service_signal(call.service, entity_id), *args) - - for service, params in CAMERA_SERVICES.items(): - hass.services.async_register(DOMAIN, service, async_service_handler, params[0]) + service.async_register_platform_entity_service( + hass, + DOMAIN, + "goto_preset", + entity_domain=CAMERA_DOMAIN, + schema={vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1))}, + func="async_goto_preset", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + "set_color_bw", + entity_domain=CAMERA_DOMAIN, + schema={vol.Required(ATTR_COLOR_BW): vol.In(CBW)}, + func="async_set_color_bw", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + "ptz_control", + entity_domain=CAMERA_DOMAIN, + schema={ + vol.Required(_ATTR_PTZ_MOV): vol.In(MOV), + vol.Optional(_ATTR_PTZ_TT, default=_DEFAULT_TT): cv.small_float, + }, + func="async_ptz_control", + ) diff --git a/homeassistant/components/amcrest/switch.py b/homeassistant/components/amcrest/switch.py index 0566e26b7ed..d3edd9d3aaf 100644 --- a/homeassistant/components/amcrest/switch.py +++ b/homeassistant/components/amcrest/switch.py @@ -1,7 +1,5 @@ """Support for Amcrest Switches.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py index ce2830d5b14..fa303b58ef1 100644 --- a/homeassistant/components/ampio/air_quality.py +++ b/homeassistant/components/ampio/air_quality.py @@ -1,7 +1,5 @@ """Support for Ampio Air Quality data.""" -from __future__ import annotations - import logging from typing import Final diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index af479587d4f..1ba8ce22256 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -1,7 +1,5 @@ """Analytics helper class for the analytics integration.""" -from __future__ import annotations - import asyncio from asyncio import timeout from collections.abc import Awaitable, Callable, Iterable, Mapping diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index b0973956c4e..89492272abc 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -1,7 +1,5 @@ """The Homeassistant Analytics integration.""" -from __future__ import annotations - from dataclasses import dataclass from python_homeassistant_analytics import ( diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index 080bb93f4ba..215427e321b 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Homeassistant Analytics integration.""" -from __future__ import annotations - from typing import Any from python_homeassistant_analytics import ( diff --git a/homeassistant/components/analytics_insights/coordinator.py b/homeassistant/components/analytics_insights/coordinator.py index fb004b8932a..4f415b7db4d 100644 --- a/homeassistant/components/analytics_insights/coordinator.py +++ b/homeassistant/components/analytics_insights/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Homeassistant Analytics integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py index d5a64e93b0a..27fd01ec3c7 100644 --- a/homeassistant/components/analytics_insights/sensor.py +++ b/homeassistant/components/analytics_insights/sensor.py @@ -1,7 +1,5 @@ """Sensor for Home Assistant analytics.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/android_ip_webcam/__init__.py b/homeassistant/components/android_ip_webcam/__init__.py index 92bb0add445..8d372883159 100644 --- a/homeassistant/components/android_ip_webcam/__init__.py +++ b/homeassistant/components/android_ip_webcam/__init__.py @@ -1,7 +1,5 @@ """The Android IP Webcam integration.""" -from __future__ import annotations - from pydroid_ipcam import PyDroidIPCam from homeassistant.const import ( diff --git a/homeassistant/components/android_ip_webcam/binary_sensor.py b/homeassistant/components/android_ip_webcam/binary_sensor.py index 67816664752..1245c4f9e5c 100644 --- a/homeassistant/components/android_ip_webcam/binary_sensor.py +++ b/homeassistant/components/android_ip_webcam/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Android IP Webcam binary sensors.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, diff --git a/homeassistant/components/android_ip_webcam/camera.py b/homeassistant/components/android_ip_webcam/camera.py index e4b0f5536a7..32fee1763e4 100644 --- a/homeassistant/components/android_ip_webcam/camera.py +++ b/homeassistant/components/android_ip_webcam/camera.py @@ -1,7 +1,5 @@ """Support for Android IP Webcam Cameras.""" -from __future__ import annotations - from homeassistant.components.camera import CameraEntityFeature from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.const import ( diff --git a/homeassistant/components/android_ip_webcam/config_flow.py b/homeassistant/components/android_ip_webcam/config_flow.py index 70870debfb1..b21420b6851 100644 --- a/homeassistant/components/android_ip_webcam/config_flow.py +++ b/homeassistant/components/android_ip_webcam/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Android IP Webcam integration.""" -from __future__ import annotations - from typing import Any from pydroid_ipcam import PyDroidIPCam diff --git a/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant/components/android_ip_webcam/sensor.py index e9d5f8514e8..465aee309b6 100644 --- a/homeassistant/components/android_ip_webcam/sensor.py +++ b/homeassistant/components/android_ip_webcam/sensor.py @@ -1,7 +1,5 @@ """Support for Android IP Webcam sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/android_ip_webcam/switch.py b/homeassistant/components/android_ip_webcam/switch.py index 3ceaf6e59b9..6ab869af532 100644 --- a/homeassistant/components/android_ip_webcam/switch.py +++ b/homeassistant/components/android_ip_webcam/switch.py @@ -1,7 +1,5 @@ """Support for Android IP Webcam settings.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index a5637053e4a..adf1a39a117 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -1,7 +1,5 @@ """Support for functionality to interact with Android/Fire TV devices.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import dataclass import logging diff --git a/homeassistant/components/androidtv/config_flow.py b/homeassistant/components/androidtv/config_flow.py index e06669f7178..12bd65da9b4 100644 --- a/homeassistant/components/androidtv/config_flow.py +++ b/homeassistant/components/androidtv/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Android Debug Bridge integration.""" -from __future__ import annotations - import logging import os from typing import Any diff --git a/homeassistant/components/androidtv/diagnostics.py b/homeassistant/components/androidtv/diagnostics.py index 3e4244d6d9f..47cf6aa5ea8 100644 --- a/homeassistant/components/androidtv/diagnostics.py +++ b/homeassistant/components/androidtv/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for AndroidTV.""" -from __future__ import annotations - from typing import Any import attr diff --git a/homeassistant/components/androidtv/entity.py b/homeassistant/components/androidtv/entity.py index fa583bb2777..1832614759b 100644 --- a/homeassistant/components/androidtv/entity.py +++ b/homeassistant/components/androidtv/entity.py @@ -1,7 +1,5 @@ """Base AndroidTV Entity.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine import functools import logging diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 57a45798364..38e84436040 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -1,7 +1,5 @@ """Support for functionality to interact with Android / Fire TV devices.""" -from __future__ import annotations - from datetime import datetime, timedelta import hashlib import logging diff --git a/homeassistant/components/androidtv/remote.py b/homeassistant/components/androidtv/remote.py index 026d1485e07..1f1dde4670f 100644 --- a/homeassistant/components/androidtv/remote.py +++ b/homeassistant/components/androidtv/remote.py @@ -1,7 +1,5 @@ """Support for the AndroidTV remote.""" -from __future__ import annotations - from collections.abc import Iterable import logging from typing import Any diff --git a/homeassistant/components/androidtv/services.py b/homeassistant/components/androidtv/services.py index 895f9d334ce..2d565628f71 100644 --- a/homeassistant/components/androidtv/services.py +++ b/homeassistant/components/androidtv/services.py @@ -1,7 +1,5 @@ """Services for Android/Fire TV 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/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 328ac863e46..d546cc7d40f 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -1,7 +1,5 @@ """The Android TV Remote integration.""" -from __future__ import annotations - from asyncio import timeout import logging diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 5fde8b28bb3..b59533aa866 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Android TV Remote integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/androidtv_remote/const.py b/homeassistant/components/androidtv_remote/const.py index 540c8186e20..4d348cff6b2 100644 --- a/homeassistant/components/androidtv_remote/const.py +++ b/homeassistant/components/androidtv_remote/const.py @@ -1,7 +1,5 @@ """Constants for the Android TV Remote integration.""" -from __future__ import annotations - from typing import Final DOMAIN: Final = "androidtv_remote" diff --git a/homeassistant/components/androidtv_remote/diagnostics.py b/homeassistant/components/androidtv_remote/diagnostics.py index add28b807e9..0438523ec14 100644 --- a/homeassistant/components/androidtv_remote/diagnostics.py +++ b/homeassistant/components/androidtv_remote/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Android TV Remote.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py index a006118afff..3f55b2bb336 100644 --- a/homeassistant/components/androidtv_remote/entity.py +++ b/homeassistant/components/androidtv_remote/entity.py @@ -1,7 +1,5 @@ """Base entity for Android TV Remote.""" -from __future__ import annotations - from typing import Any from androidtvremote2 import AndroidTVRemote, ConnectionClosed diff --git a/homeassistant/components/androidtv_remote/helpers.py b/homeassistant/components/androidtv_remote/helpers.py index c267677f1f7..6fc40495160 100644 --- a/homeassistant/components/androidtv_remote/helpers.py +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -1,7 +1,5 @@ """Helper functions for Android TV Remote integration.""" -from __future__ import annotations - from androidtvremote2 import AndroidTVRemote from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py index 371c97cc33e..f2517f6b13a 100644 --- a/homeassistant/components/androidtv_remote/media_player.py +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -1,7 +1,5 @@ """Media player support for Android TV Remote.""" -from __future__ import annotations - import asyncio from typing import Any diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index 612d27de189..215a93ee699 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -1,7 +1,5 @@ """Remote control support for Android TV Remote.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable from typing import Any diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py index 97691c8b028..4950210a5c2 100644 --- a/homeassistant/components/anel_pwrctrl/switch.py +++ b/homeassistant/components/anel_pwrctrl/switch.py @@ -1,7 +1,5 @@ """Support for ANEL PwrCtrl switches.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/anglian_water/__init__.py b/homeassistant/components/anglian_water/__init__.py index a6532b4affc..6935b4c14e8 100644 --- a/homeassistant/components/anglian_water/__init__.py +++ b/homeassistant/components/anglian_water/__init__.py @@ -1,7 +1,5 @@ """The Anglian Water integration.""" -from __future__ import annotations - from aiohttp import CookieJar from pyanglianwater import AnglianWater from pyanglianwater.auth import MSOB2CAuth diff --git a/homeassistant/components/anglian_water/config_flow.py b/homeassistant/components/anglian_water/config_flow.py index 8421e2d35e1..430aa60faff 100644 --- a/homeassistant/components/anglian_water/config_flow.py +++ b/homeassistant/components/anglian_water/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Anglian Water integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/anglian_water/coordinator.py b/homeassistant/components/anglian_water/coordinator.py index 81c845420a6..ff80224acd6 100644 --- a/homeassistant/components/anglian_water/coordinator.py +++ b/homeassistant/components/anglian_water/coordinator.py @@ -1,7 +1,5 @@ """Anglian Water data coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any @@ -92,6 +90,7 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]): _LOGGER.debug("Updating statistics for the first time") usage_sum = 0.0 last_stats_time = None + allow_update_last_stored_hour = False else: if not meter.readings or len(meter.readings) == 0: _LOGGER.debug("No recent usage statistics found, skipping update") @@ -107,6 +106,7 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]): continue start = dt_util.as_local(parsed_read_at) - timedelta(hours=1) _LOGGER.debug("Getting statistics at %s", start) + stats: dict[str, list[Any]] = {} for end in (start + timedelta(seconds=1), None): stats = await get_instance(self.hass).async_add_executor_job( statistics_during_period, @@ -127,15 +127,28 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]): "Not found, trying to find oldest statistic after %s", start, ) - assert stats - def _safe_get_sum(records: list[Any]) -> float: - if records and "sum" in records[0]: - return float(records[0]["sum"]) - return 0.0 + if not stats or not stats.get(usage_statistic_id): + _LOGGER.debug( + "Could not find existing statistics during period lookup for %s, " + "falling back to last stored statistic", + usage_statistic_id, + ) + allow_update_last_stored_hour = True + last_records = last_stat[usage_statistic_id] + usage_sum = float(last_records[0].get("sum") or 0.0) + last_stats_time = last_records[0]["start"] + else: + allow_update_last_stored_hour = False + records = stats[usage_statistic_id] - usage_sum = _safe_get_sum(stats.get(usage_statistic_id, [])) - last_stats_time = stats[usage_statistic_id][0]["start"] + def _safe_get_sum(records: list[Any]) -> float: + if records and "sum" in records[0]: + return float(records[0]["sum"]) + return 0.0 + + usage_sum = _safe_get_sum(records) + last_stats_time = records[0]["start"] usage_statistics = [] @@ -148,7 +161,13 @@ class AnglianWaterUpdateCoordinator(DataUpdateCoordinator[None]): ) continue start = dt_util.as_local(parsed_read_at) - timedelta(hours=1) - if last_stats_time is not None and start.timestamp() <= last_stats_time: + if last_stats_time is not None and ( + start.timestamp() < last_stats_time + or ( + start.timestamp() == last_stats_time + and not allow_update_last_stored_hour + ) + ): continue usage_state = max(0, read["consumption"] / 1000) usage_sum = max(0, read["read"]) diff --git a/homeassistant/components/anglian_water/entity.py b/homeassistant/components/anglian_water/entity.py index 81d3f6a6a7f..d06374881b1 100644 --- a/homeassistant/components/anglian_water/entity.py +++ b/homeassistant/components/anglian_water/entity.py @@ -1,7 +1,5 @@ """Anglian Water entity.""" -from __future__ import annotations - import logging from pyanglianwater.meter import SmartMeter diff --git a/homeassistant/components/anglian_water/sensor.py b/homeassistant/components/anglian_water/sensor.py index 52cd629f8bb..26add0a7fc8 100644 --- a/homeassistant/components/anglian_water/sensor.py +++ b/homeassistant/components/anglian_water/sensor.py @@ -1,7 +1,5 @@ """Anglian Water sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/anova/__init__.py b/homeassistant/components/anova/__init__.py index 9307cfc4bdd..92ae61a96f1 100644 --- a/homeassistant/components/anova/__init__.py +++ b/homeassistant/components/anova/__init__.py @@ -1,7 +1,5 @@ """The Anova integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/anova/config_flow.py b/homeassistant/components/anova/config_flow.py index f382606baba..3385a26433e 100644 --- a/homeassistant/components/anova/config_flow.py +++ b/homeassistant/components/anova/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Anova.""" -from __future__ import annotations - import logging from anova_wifi import AnovaApi, InvalidLogin diff --git a/homeassistant/components/anova/coordinator.py b/homeassistant/components/anova/coordinator.py index 61d118ed0a5..4832380ffd5 100644 --- a/homeassistant/components/anova/coordinator.py +++ b/homeassistant/components/anova/coordinator.py @@ -1,7 +1,5 @@ """Support for Anova Coordinators.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/anova/entity.py b/homeassistant/components/anova/entity.py index 54492f3775e..f0b04faba83 100644 --- a/homeassistant/components/anova/entity.py +++ b/homeassistant/components/anova/entity.py @@ -1,7 +1,5 @@ """Base entity for the Anova integration.""" -from __future__ import annotations - from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index c3a3d3861f2..c69f84b15cd 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -1,7 +1,5 @@ """Support for Anova Sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/anthemav/__init__.py b/homeassistant/components/anthemav/__init__.py index 9616d554424..2185d0ddf4a 100644 --- a/homeassistant/components/anthemav/__init__.py +++ b/homeassistant/components/anthemav/__init__.py @@ -1,7 +1,5 @@ """The Anthem A/V Receivers integration.""" -from __future__ import annotations - import logging import anthemav diff --git a/homeassistant/components/anthemav/config_flow.py b/homeassistant/components/anthemav/config_flow.py index fe9c6513041..12e6f1fc7f5 100644 --- a/homeassistant/components/anthemav/config_flow.py +++ b/homeassistant/components/anthemav/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Anthem A/V Receivers integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py index 317498e96b5..5b1f6cebe86 100644 --- a/homeassistant/components/anthemav/media_player.py +++ b/homeassistant/components/anthemav/media_player.py @@ -1,7 +1,5 @@ """Support for Anthem Network Receivers and Processors.""" -from __future__ import annotations - import logging from anthemav.protocol import AVR diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 9011ad21e42..10310ff1ef7 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -1,35 +1,24 @@ """The Anthropic integration.""" -from __future__ import annotations +from anthropic.resources.messages.messages import DEPRECATED_MODELS -import anthropic - -from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, issue_registry as ir, ) -from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_CHAT_MODEL, - DEFAULT_CONVERSATION_NAME, - DEPRECATED_MODELS, - DOMAIN, - LOGGER, -) +from .const import CONF_CHAT_MODEL, DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER +from .coordinator import AnthropicConfigEntry, AnthropicCoordinator PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient] - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Anthropic.""" @@ -39,38 +28,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool: """Set up Anthropic from a config entry.""" - client = anthropic.AsyncAnthropic( - api_key=entry.data[CONF_API_KEY], http_client=get_async_client(hass) - ) - try: - await client.models.list(timeout=10.0) - except anthropic.AuthenticationError as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="api_authentication_error", - translation_placeholders={"message": err.message}, - ) from err - except anthropic.AnthropicError as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="api_error", - translation_placeholders={ - "message": err.message - if isinstance(err, anthropic.APIError) - else str(err) - }, - ) from err - - entry.runtime_data = client + coordinator = AnthropicCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + LOGGER.debug("Available models: %s", coordinator.data) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_update_options)) for subentry in entry.subentries.values(): - if (model := subentry.data.get(CONF_CHAT_MODEL)) and model.startswith( - tuple(DEPRECATED_MODELS) - ): + if (model := subentry.data.get(CONF_CHAT_MODEL)) and model in DEPRECATED_MODELS: ir.async_create_issue( hass, DOMAIN, @@ -260,6 +228,19 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ) hass.config_entries.async_update_entry(entry, minor_version=3) + if entry.version == 2 and entry.minor_version == 3: + # Remove Temperature parameter + CONF_TEMPERATURE = "temperature" + + for subentry in entry.subentries.values(): + data = subentry.data.copy() + if CONF_TEMPERATURE not in data: + continue + data.pop(CONF_TEMPERATURE, None) + hass.config_entries.async_update_subentry(entry, subentry, data=data) + + hass.config_entries.async_update_entry(entry, minor_version=4) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/homeassistant/components/anthropic/ai_task.py b/homeassistant/components/anthropic/ai_task.py index 5445b654397..d1551942aa4 100644 --- a/homeassistant/components/anthropic/ai_task.py +++ b/homeassistant/components/anthropic/ai_task.py @@ -1,7 +1,5 @@ """AI Task integration for Anthropic.""" -from __future__ import annotations - from json import JSONDecodeError import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 36c4a80f85d..ce45cf1b686 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -1,11 +1,8 @@ """Config flow for Anthropic integration.""" -from __future__ import annotations - from collections.abc import Mapping import json import logging -import re from typing import TYPE_CHECKING, Any, cast import anthropic @@ -30,6 +27,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( NumberSelector, @@ -43,15 +41,15 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.typing import VolDictType from .const import ( - CODE_EXECUTION_UNSUPPORTED_MODELS, CONF_CHAT_MODEL, CONF_CODE_EXECUTION, CONF_MAX_TOKENS, CONF_PROMPT, + CONF_PROMPT_CACHING, CONF_RECOMMENDED, - CONF_TEMPERATURE, CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, + CONF_TOOL_SEARCH, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_COUNTRY, @@ -63,10 +61,11 @@ from .const import ( DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DOMAIN, - NON_ADAPTIVE_THINKING_MODELS, - NON_THINKING_MODELS, - WEB_SEARCH_UNSUPPORTED_MODELS, + MIN_THINKING_BUDGET, + TOOL_SEARCH_UNSUPPORTED_MODELS, + PromptCaching, ) +from .coordinator import model_alias if TYPE_CHECKING: from . import AnthropicConfigEntry @@ -101,39 +100,11 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: await client.models.list(timeout=10.0) -async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionDict]: - """Get list of available models.""" - try: - models = (await client.models.list()).data - except anthropic.AnthropicError: - models = [] - _LOGGER.debug("Available models: %s", models) - model_options: list[SelectOptionDict] = [] - short_form = re.compile(r"[^\d]-\d$") - for model_info in models: - # Resolve alias from versioned model name: - model_alias = ( - model_info.id[:-9] - if model_info.id != "claude-3-haiku-20240307" - and model_info.id[-2:-1] != "-" - else model_info.id - ) - if short_form.search(model_alias): - model_alias += "-0" - model_options.append( - SelectOptionDict( - label=model_info.display_name, - value=model_alias, - ) - ) - return model_options - - class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthropic.""" VERSION = 2 - MINOR_VERSION = 3 + MINOR_VERSION = 4 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -225,6 +196,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): """Flow for managing conversation subentries.""" options: dict[str, Any] + model_info: anthropic.types.ModelInfo @property def _is_new(self) -> bool: @@ -338,29 +310,49 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """Manage advanced options.""" errors: dict[str, str] = {} + description_placeholders: dict[str, str] = {} step_schema: VolDictType = { vol.Optional( CONF_CHAT_MODEL, default=DEFAULT[CONF_CHAT_MODEL], ): SelectSelector( - SelectSelectorConfig( - options=await self._get_model_list(), custom_value=True - ) + SelectSelectorConfig(options=self._get_model_list(), custom_value=True) ), vol.Optional( - CONF_MAX_TOKENS, - default=DEFAULT[CONF_MAX_TOKENS], - ): int, - vol.Optional( - CONF_TEMPERATURE, - default=DEFAULT[CONF_TEMPERATURE], - ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + CONF_PROMPT_CACHING, + default=DEFAULT[CONF_PROMPT_CACHING], + ): SelectSelector( + SelectSelectorConfig( + options=[x.value for x in PromptCaching], + translation_key=CONF_PROMPT_CACHING, + mode=SelectSelectorMode.DROPDOWN, + ) + ), } if user_input is not None: self.options.update(user_input) + coordinator = self._get_entry().runtime_data + self.model_info, status = coordinator.get_model_info( + self.options[CONF_CHAT_MODEL] + ) + if not status: + # Couldn't find the model in the cached list, try to fetch it directly + client = coordinator.client + try: + self.model_info = await client.models.retrieve( + self.options[CONF_CHAT_MODEL], timeout=10.0 + ) + except anthropic.NotFoundError: + errors[CONF_CHAT_MODEL] = "model_not_found" + except anthropic.AnthropicError as err: + errors[CONF_CHAT_MODEL] = "api_error" + description_placeholders["message"] = ( + err.message if isinstance(err, anthropic.APIError) else str(err) + ) + if not errors: return await self.async_step_model() @@ -370,6 +362,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): vol.Schema(step_schema), self.options ), errors=errors, + description_placeholders=description_placeholders, ) async def async_step_model( @@ -378,30 +371,59 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): """Manage model-specific options.""" errors: dict[str, str] = {} - step_schema: VolDictType = {} + step_schema: VolDictType = { + vol.Optional( + CONF_MAX_TOKENS, + default=DEFAULT[CONF_MAX_TOKENS], + ): vol.All( + NumberSelector( + NumberSelectorConfig(min=0, max=self.model_info.max_tokens) + ), + vol.Coerce(int), + ) + if self.model_info.max_tokens + else cv.positive_int, + } - model = self.options[CONF_CHAT_MODEL] - - if not model.startswith(tuple(NON_THINKING_MODELS)) and model.startswith( - tuple(NON_ADAPTIVE_THINKING_MODELS) + if ( + self.model_info.capabilities + and self.model_info.capabilities.thinking.supported + and not self.model_info.capabilities.thinking.types.adaptive.supported ): step_schema[ vol.Optional( CONF_THINKING_BUDGET, default=DEFAULT[CONF_THINKING_BUDGET] ) - ] = vol.All( - NumberSelector( - NumberSelectorConfig( - min=0, - max=self.options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]), - ) - ), - vol.Coerce(int), + ] = ( + vol.All( + NumberSelector( + NumberSelectorConfig(min=0, max=self.model_info.max_tokens) + ), + vol.Coerce(int), + ) + if self.model_info.max_tokens + else cv.positive_int ) else: self.options.pop(CONF_THINKING_BUDGET, None) - if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)): + if ( + self.model_info.capabilities + and (effort_capability := self.model_info.capabilities.effort).supported + ): + effort_options: list[str] = [] + if self.model_info.capabilities.thinking.types.adaptive.supported: + effort_options.append("none") + if effort_capability.low.supported: + effort_options.append("low") + if effort_capability.medium.supported: + effort_options.append("medium") + if effort_capability.high.supported: + effort_options.append("high") + if effort_capability.xhigh and effort_capability.xhigh.supported: + effort_options.append("xhigh") + if effort_capability.max.supported: + effort_options.append("max") step_schema[ vol.Optional( CONF_THINKING_EFFORT, @@ -409,7 +431,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): ) ] = SelectSelector( SelectSelectorConfig( - options=["none", "low", "medium", "high", "max"], + options=effort_options, translation_key=CONF_THINKING_EFFORT, mode=SelectSelectorMode.DROPDOWN, ) @@ -417,47 +439,58 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): else: self.options.pop(CONF_THINKING_EFFORT, None) - if not model.startswith(tuple(CODE_EXECUTION_UNSUPPORTED_MODELS)): - step_schema[ + step_schema.update( + { vol.Optional( CONF_CODE_EXECUTION, default=DEFAULT[CONF_CODE_EXECUTION], - ) - ] = bool - else: - self.options.pop(CONF_CODE_EXECUTION, None) - - if not model.startswith(tuple(WEB_SEARCH_UNSUPPORTED_MODELS)): - step_schema.update( - { - vol.Optional( - CONF_WEB_SEARCH, - default=DEFAULT[CONF_WEB_SEARCH], - ): bool, - vol.Optional( - CONF_WEB_SEARCH_MAX_USES, - default=DEFAULT[CONF_WEB_SEARCH_MAX_USES], - ): int, - vol.Optional( - CONF_WEB_SEARCH_USER_LOCATION, - default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION], - ): bool, - } - ) - else: - self.options.pop(CONF_WEB_SEARCH, None) - self.options.pop(CONF_WEB_SEARCH_MAX_USES, None) - self.options.pop(CONF_WEB_SEARCH_USER_LOCATION, None) + ): bool, + vol.Optional( + CONF_WEB_SEARCH, + default=DEFAULT[CONF_WEB_SEARCH], + ): bool, + vol.Optional( + CONF_WEB_SEARCH_MAX_USES, + default=DEFAULT[CONF_WEB_SEARCH_MAX_USES], + ): int, + vol.Optional( + CONF_WEB_SEARCH_USER_LOCATION, + default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION], + ): bool, + } + ) self.options.pop(CONF_WEB_SEARCH_CITY, None) self.options.pop(CONF_WEB_SEARCH_REGION, None) self.options.pop(CONF_WEB_SEARCH_COUNTRY, None) self.options.pop(CONF_WEB_SEARCH_TIMEZONE, None) + model = self.options[CONF_CHAT_MODEL] + + if not model.startswith(tuple(TOOL_SEARCH_UNSUPPORTED_MODELS)): + step_schema[ + vol.Optional( + CONF_TOOL_SEARCH, + default=DEFAULT[CONF_TOOL_SEARCH], + ) + ] = bool + else: + self.options.pop(CONF_TOOL_SEARCH, None) + if not step_schema: - user_input = {} + # Currently our schema is always present, but if one day it becomes empty, + # then the below line is needed to skip this step + user_input = {} # pragma: no cover if user_input is not None: + if ( + CONF_THINKING_BUDGET in user_input + and user_input[CONF_THINKING_BUDGET] >= MIN_THINKING_BUDGET + and user_input[CONF_THINKING_BUDGET] + >= user_input.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]) + ): + errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large" + if user_input.get(CONF_WEB_SEARCH, DEFAULT[CONF_WEB_SEARCH]) and not errors: if user_input.get( CONF_WEB_SEARCH_USER_LOCATION, @@ -489,13 +522,16 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): last_step=True, ) - async def _get_model_list(self) -> list[SelectOptionDict]: + def _get_model_list(self) -> list[SelectOptionDict]: """Get list of available models.""" - client = anthropic.AsyncAnthropic( - api_key=self._get_entry().data[CONF_API_KEY], - http_client=get_async_client(self.hass), - ) - return await get_model_list(client) + coordinator = self._get_entry().runtime_data + return [ + SelectOptionDict( + label=model_info.display_name, + value=model_alias(model_info.id), + ) + for model_info in coordinator.data or [] + ] async def _get_location_data(self) -> dict[str, str]: """Get approximate location data of the user.""" diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 138f704aa0c..a1fa522ffae 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -1,5 +1,6 @@ """Constants for the Anthropic integration.""" +from enum import StrEnum import logging DOMAIN = "anthropic" @@ -13,9 +14,10 @@ CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" CONF_CODE_EXECUTION = "code_execution" CONF_MAX_TOKENS = "max_tokens" -CONF_TEMPERATURE = "temperature" +CONF_PROMPT_CACHING = "prompt_caching" CONF_THINKING_BUDGET = "thinking_budget" CONF_THINKING_EFFORT = "thinking_effort" +CONF_TOOL_SEARCH = "tool_search" CONF_WEB_SEARCH = "web_search" CONF_WEB_SEARCH_USER_LOCATION = "user_location" CONF_WEB_SEARCH_MAX_USES = "web_search_max_uses" @@ -24,53 +26,30 @@ CONF_WEB_SEARCH_REGION = "region" CONF_WEB_SEARCH_COUNTRY = "country" CONF_WEB_SEARCH_TIMEZONE = "timezone" + +class PromptCaching(StrEnum): + """Prompt caching options.""" + + OFF = "off" + PROMPT = "prompt" + AUTOMATIC = "automatic" + + +MIN_THINKING_BUDGET = 1024 + DEFAULT = { CONF_CHAT_MODEL: "claude-haiku-4-5", CONF_CODE_EXECUTION: False, CONF_MAX_TOKENS: 3000, - CONF_TEMPERATURE: 1.0, - CONF_THINKING_BUDGET: 0, + CONF_PROMPT_CACHING: PromptCaching.PROMPT.value, + CONF_THINKING_BUDGET: MIN_THINKING_BUDGET, CONF_THINKING_EFFORT: "low", + CONF_TOOL_SEARCH: False, CONF_WEB_SEARCH: False, CONF_WEB_SEARCH_USER_LOCATION: False, CONF_WEB_SEARCH_MAX_USES: 5, } -MIN_THINKING_BUDGET = 1024 - -NON_THINKING_MODELS = [ - "claude-3-haiku", -] - -NON_ADAPTIVE_THINKING_MODELS = [ - "claude-opus-4-5", - "claude-sonnet-4-5", - "claude-haiku-4-5", - "claude-opus-4-1", - "claude-opus-4-0", - "claude-opus-4-20250514", - "claude-sonnet-4-0", - "claude-sonnet-4-20250514", - "claude-3-haiku", -] - -UNSUPPORTED_STRUCTURED_OUTPUT_MODELS = [ - "claude-opus-4-1", - "claude-opus-4-0", - "claude-opus-4-20250514", - "claude-sonnet-4-0", - "claude-sonnet-4-20250514", - "claude-3-haiku", -] - -WEB_SEARCH_UNSUPPORTED_MODELS = [ - "claude-3-haiku", -] - -CODE_EXECUTION_UNSUPPORTED_MODELS = [ - "claude-3-haiku", -] - -DEPRECATED_MODELS = [ - "claude-3", +TOOL_SEARCH_UNSUPPORTED_MODELS = [ + "claude-haiku", ] diff --git a/homeassistant/components/anthropic/coordinator.py b/homeassistant/components/anthropic/coordinator.py new file mode 100644 index 00000000000..9cd66174d41 --- /dev/null +++ b/homeassistant/components/anthropic/coordinator.py @@ -0,0 +1,111 @@ +"""Coordinator for the Anthropic integration.""" + +import datetime +import re + +import anthropic + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +UPDATE_INTERVAL_CONNECTED = datetime.timedelta(hours=12) +UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1) + +type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator] + + +_model_short_form = re.compile(r"[^\d]-\d$") + + +@callback +def model_alias(model_id: str) -> str: + """Resolve alias from versioned model name.""" + if model_id[-2:-1] != "-" and not model_id.endswith("-preview"): + model_id = model_id[:-9] + if _model_short_form.search(model_id): + return model_id + "-0" + return model_id + + +class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]]): + """DataUpdateCoordinator which uses different intervals after successful and unsuccessful updates.""" + + client: anthropic.AsyncAnthropic + + def __init__(self, hass: HomeAssistant, config_entry: AnthropicConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=config_entry.title, + update_interval=UPDATE_INTERVAL_CONNECTED, + update_method=self.async_update_data, + always_update=False, + ) + self.client = anthropic.AsyncAnthropic( + api_key=config_entry.data[CONF_API_KEY], http_client=get_async_client(hass) + ) + + @callback + def async_set_updated_data(self, data: list[anthropic.types.ModelInfo]) -> None: + """Manually update data, notify listeners and update refresh interval.""" + self.update_interval = UPDATE_INTERVAL_CONNECTED + super().async_set_updated_data(data) + + async def async_update_data(self) -> list[anthropic.types.ModelInfo]: + """Fetch data from the API.""" + try: + self.update_interval = UPDATE_INTERVAL_DISCONNECTED + result = await self.client.models.list(timeout=10.0) + self.update_interval = UPDATE_INTERVAL_CONNECTED + except anthropic.APITimeoutError as err: + raise TimeoutError(err.message or str(err)) from err + except anthropic.AuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="api_authentication_error", + translation_placeholders={"message": err.message}, + ) from err + except anthropic.APIError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": err.message}, + ) from err + return result.data + + def mark_connection_error(self) -> None: + """Mark the connection as having an error and reschedule background check.""" + self.update_interval = UPDATE_INTERVAL_DISCONNECTED + if self.last_update_success: + self.last_update_success = False + self.async_update_listeners() + if self._listeners and not self.hass.is_stopping: + self._schedule_refresh() + + @callback + def get_model_info(self, model_id: str) -> tuple[anthropic.types.ModelInfo, bool]: + """Get model info for a given model ID.""" + # First try: exact name match + for model in self.data or []: + if model.id == model_id: + return model, True + # Second try: match by alias + alias = model_alias(model_id) + for model in self.data or []: + if model_alias(model.id) == alias: + return model, True + # Model not found, return safe defaults + return anthropic.types.ModelInfo( + type="model", + id=model_id, + created_at=datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC), + display_name=alias, + ), False diff --git a/homeassistant/components/anthropic/diagnostics.py b/homeassistant/components/anthropic/diagnostics.py new file mode 100644 index 00000000000..f6d9b0d7a29 --- /dev/null +++ b/homeassistant/components/anthropic/diagnostics.py @@ -0,0 +1,62 @@ +"""Diagnostics support for Anthropic.""" + +from typing import TYPE_CHECKING, Any + +from anthropic import __title__, __version__ + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_API_KEY +from homeassistant.helpers import entity_registry as er + +from .const import ( + CONF_PROMPT, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, +) + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + from . import AnthropicConfigEntry + + +TO_REDACT = { + CONF_API_KEY, + CONF_PROMPT, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_TIMEZONE, +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: AnthropicConfigEntry +) -> 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/anthropic/entity.py b/homeassistant/components/anthropic/entity.py index 94c8616d010..bd2782c3eb1 100644 --- a/homeassistant/components/anthropic/entity.py +++ b/homeassistant/components/anthropic/entity.py @@ -1,7 +1,8 @@ """Base entity for Anthropic.""" import base64 -from collections.abc import AsyncGenerator, Callable, Iterable +from collections import deque +from collections.abc import AsyncIterator, Callable, Iterable from dataclasses import dataclass, field from datetime import UTC, datetime import json @@ -19,16 +20,23 @@ from anthropic.types import ( CitationsWebSearchResultLocation, CitationWebSearchResultLocationParam, CodeExecutionTool20250825Param, + CodeExecutionToolResultBlock, + CodeExecutionToolResultBlockContent, + CodeExecutionToolResultBlockParamContentParam, Container, + ContentBlock, ContentBlockParam, DocumentBlockParam, ImageBlockParam, InputJSONDelta, JSONOutputFormatParam, + Message, MessageDeltaUsage, MessageParam, MessageStreamEvent, + ModelInfo, OutputConfigParam, + RawContentBlockDelta, RawContentBlockDeltaEvent, RawContentBlockStartEvent, RawContentBlockStopEvent, @@ -56,21 +64,39 @@ from anthropic.types import ( ToolChoiceAutoParam, ToolChoiceToolParam, ToolParam, + ToolSearchToolBm25_20251119Param, + ToolSearchToolResultBlock, ToolUnionParam, ToolUseBlock, ToolUseBlockParam, Usage, WebSearchTool20250305Param, + WebSearchTool20260209Param, WebSearchToolResultBlock, + WebSearchToolResultBlockContent, WebSearchToolResultBlockParamContentParam, ) +from anthropic.types.bash_code_execution_tool_result_block import ( + Content as BashCodeExecutionToolResultBlockContent, +) from anthropic.types.bash_code_execution_tool_result_block_param import ( - Content as BashCodeExecutionToolResultContentParam, + Content as BashCodeExecutionToolResultBlockParamContentParam, ) from anthropic.types.message_create_params import MessageCreateParamsStreaming -from anthropic.types.text_editor_code_execution_tool_result_block_param import ( - Content as TextEditorCodeExecutionToolResultContentParam, +from anthropic.types.raw_message_delta_event import Delta +from anthropic.types.text_editor_code_execution_tool_result_block import ( + Content as TextEditorCodeExecutionToolResultBlockContent, ) +from anthropic.types.text_editor_code_execution_tool_result_block_param import ( + Content as TextEditorCodeExecutionToolResultBlockParamContentParam, +) +from anthropic.types.tool_search_tool_result_block import ( + Content as ToolSearchToolResultBlockContent, +) +from anthropic.types.tool_search_tool_result_block_param import ( + Content as ToolSearchToolResultBlockParamContentParam, +) +from anthropic.types.tool_use_block import Caller import voluptuous as vol from voluptuous_openapi import convert @@ -79,19 +105,19 @@ from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant 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 homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import slugify -from homeassistant.util.json import JsonObjectType +from homeassistant.util.json import JsonArrayType, JsonObjectType -from . import AnthropicConfigEntry from .const import ( CONF_CHAT_MODEL, CONF_CODE_EXECUTION, CONF_MAX_TOKENS, - CONF_TEMPERATURE, + CONF_PROMPT_CACHING, CONF_THINKING_BUDGET, CONF_THINKING_EFFORT, + CONF_TOOL_SEARCH, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_COUNTRY, @@ -103,10 +129,9 @@ from .const import ( DOMAIN, LOGGER, MIN_THINKING_BUDGET, - NON_ADAPTIVE_THINKING_MODELS, - NON_THINKING_MODELS, - UNSUPPORTED_STRUCTURED_OUTPUT_MODELS, + PromptCaching, ) +from .coordinator import AnthropicConfigEntry, AnthropicCoordinator # Max number of back and forth with the LLM to generate a response MAX_TOOL_ITERATIONS = 10 @@ -116,10 +141,14 @@ def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None ) -> ToolParam: """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} + return ToolParam( name=tool.name, description=tool.description or "", - input_schema=convert(tool.parameters, custom_serializer=custom_serializer), + input_schema=schema, ) @@ -198,7 +227,7 @@ class ContentDetails: ] -def _convert_content( +def _convert_content( # noqa: C901 chat_content: Iterable[conversation.Content], ) -> tuple[list[MessageParam], str | None]: """Transform HA chat_log content into Anthropic API format.""" @@ -224,12 +253,22 @@ def _convert_content( }, ), } + elif content.tool_name == "code_execution": + tool_result_block = { + "type": "code_execution_tool_result", + "tool_use_id": content.tool_call_id, + "content": cast( + CodeExecutionToolResultBlockParamContentParam, + content.tool_result, + ), + } elif content.tool_name == "bash_code_execution": tool_result_block = { "type": "bash_code_execution_tool_result", "tool_use_id": content.tool_call_id, "content": cast( - BashCodeExecutionToolResultContentParam, content.tool_result + BashCodeExecutionToolResultBlockParamContentParam, + content.tool_result, ), } elif content.tool_name == "text_editor_code_execution": @@ -237,7 +276,16 @@ def _convert_content( "type": "text_editor_code_execution_tool_result", "tool_use_id": content.tool_call_id, "content": cast( - TextEditorCodeExecutionToolResultContentParam, + TextEditorCodeExecutionToolResultBlockParamContentParam, + content.tool_result, + ), + } + elif content.tool_name == "tool_search": + tool_result_block = { + "type": "tool_search_tool_result", + "tool_use_id": content.tool_call_id, + "content": cast( + ToolSearchToolResultBlockParamContentParam, content.tool_result, ), } @@ -368,8 +416,10 @@ def _convert_content( name=cast( Literal[ "web_search", + "code_execution", "bash_code_execution", "text_editor_code_execution", + "tool_search_tool_bm25", ], tool_call.tool_name, ), @@ -379,8 +429,10 @@ def _convert_content( and tool_call.tool_name in [ "web_search", + "code_execution", "bash_code_execution", "text_editor_code_execution", + "tool_search_tool_bm25", ] else ToolUseBlockParam( type="tool_use", @@ -410,13 +462,7 @@ def _convert_content( return messages, container_id -async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place - chat_log: conversation.ChatLog, - stream: AsyncStream[MessageStreamEvent], - output_tool: str | None = None, -) -> AsyncGenerator[ - conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict -]: +class AnthropicDeltaStream: """Transform the response stream into HA format. A typical stream of responses might look something like the following: @@ -446,202 +492,379 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have Each message could contain multiple blocks of the same type. """ - if stream is None or not hasattr(stream, "__aiter__"): - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="unexpected_stream_object" + + def __init__( + self, + chat_log: conversation.ChatLog, + stream: AsyncStream[MessageStreamEvent], + output_tool: str | None = None, + ) -> None: + """Initialize the delta stream.""" + self._chat_log: conversation.ChatLog = chat_log + self._stream: AsyncStream[MessageStreamEvent] = stream + self._output_tool: str | None = output_tool + + self._buffer: deque[ + conversation.AssistantContentDeltaDict + | conversation.ToolResultContentDeltaDict + ] = deque() + self._stream_iterator: AsyncIterator[MessageStreamEvent] | None = None + + self._current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = ( + None ) + self._current_tool_args: str = "" + self._content_details = ContentDetails() + self._content_details.add_citation_detail() + self._input_usage: Usage | None = None + self._first_block: bool = True - current_tool_block: ToolUseBlockParam | ServerToolUseBlockParam | None = None - current_tool_args: str - content_details = ContentDetails() - content_details.add_citation_detail() - input_usage: Usage | None = None - first_block: bool = True + def __aiter__( + self, + ) -> AsyncIterator[ + conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict + ]: + """Initialize the stream and return the async iterator.""" + if self._stream is None or not hasattr(self._stream, "__aiter__"): + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="unexpected_stream_object" + ) + if self._stream_iterator is None: + self._stream_iterator = self._stream.__aiter__() + return self - async for response in stream: - LOGGER.debug("Received response: %s", response) + async def __anext__( + self, + ) -> ( + conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict + ): + """Get the next item from the stream.""" + while True: + if self._buffer: + return self._buffer.popleft() - if isinstance(response, RawMessageStartEvent): - input_usage = response.message.usage - first_block = True - elif isinstance(response, RawContentBlockStartEvent): - if isinstance(response.content_block, ToolUseBlock): - current_tool_block = ToolUseBlockParam( - type="tool_use", - id=response.content_block.id, - name=response.content_block.name, - input={}, - ) - current_tool_args = "" - if response.content_block.name == output_tool: - if first_block or content_details.has_content(): - if content_details: - content_details.delete_empty() - yield {"native": content_details} - content_details = ContentDetails() - content_details.add_citation_detail() - yield {"role": "assistant"} - first_block = False - elif isinstance(response.content_block, TextBlock): - if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead. - first_block - or ( - not content_details.has_citations() - and response.content_block.citations is None - and content_details.has_content() - ) - ): - if content_details: - content_details.delete_empty() - yield {"native": content_details} - content_details = ContentDetails() - yield {"role": "assistant"} - first_block = False - content_details.add_citation_detail() - if response.content_block.text: - content_details.citation_details[-1].length += len( - response.content_block.text - ) - yield {"content": response.content_block.text} - elif isinstance(response.content_block, ThinkingBlock): - if first_block or content_details.thinking_signature: - if content_details: - content_details.delete_empty() - yield {"native": content_details} - content_details = ContentDetails() - content_details.add_citation_detail() - yield {"role": "assistant"} - first_block = False - elif isinstance(response.content_block, RedactedThinkingBlock): - LOGGER.debug( - "Some of Claude’s internal reasoning has been automatically " - "encrypted for safety reasons. This doesn’t affect the quality of " - "responses" - ) - if first_block or content_details.redacted_thinking: - if content_details: - content_details.delete_empty() - yield {"native": content_details} - content_details = ContentDetails() - content_details.add_citation_detail() - yield {"role": "assistant"} - first_block = False - content_details.redacted_thinking = response.content_block.data - elif isinstance(response.content_block, ServerToolUseBlock): - current_tool_block = ServerToolUseBlockParam( - type="server_tool_use", - id=response.content_block.id, - name=response.content_block.name, - input={}, - ) - current_tool_args = "" - elif isinstance( - response.content_block, - ( - WebSearchToolResultBlock, - BashCodeExecutionToolResultBlock, - TextEditorCodeExecutionToolResultBlock, - ), - ): - if content_details: - content_details.delete_empty() - yield {"native": content_details} - content_details = ContentDetails() - content_details.add_citation_detail() - yield { - "role": "tool_result", - "tool_call_id": response.content_block.tool_use_id, - "tool_name": response.content_block.type.removesuffix( - "_tool_result" - ), - "tool_result": { - "content": cast( - JsonObjectType, response.content_block.to_dict()["content"] - ) - } - if isinstance(response.content_block.content, list) - else cast(JsonObjectType, response.content_block.content.to_dict()), + response = await self._stream_iterator.__anext__() # type: ignore[union-attr] + + LOGGER.debug("Received response: %s", response) + self.on_message_stream_event(response) + + def on_message_stream_event(self, event: MessageStreamEvent) -> None: + """Handle MessageStreamEvent.""" + if isinstance(event, RawMessageStartEvent): + self.on_message_start_event(event.message) + return + if isinstance(event, RawContentBlockStartEvent): + self.on_content_block_start_event(event.content_block, event.index) + return + if isinstance(event, RawContentBlockDeltaEvent): + self.on_content_block_delta_event(event.delta) + return + if isinstance(event, RawContentBlockStopEvent): + self.on_content_block_stop_event(event.index) + return + if isinstance(event, RawMessageDeltaEvent): + self.on_message_delta_event(event.delta, event.usage) + return + if isinstance(event, RawMessageStopEvent): + self.on_message_stop_event() + return + LOGGER.debug("Unhandled event type: %s", event.type) # type: ignore[unreachable] # pragma: no cover - All types are handled but we want to verify that + + def on_message_start_event(self, message: Message) -> None: + """Handle RawMessageStartEvent.""" + self._input_usage = message.usage + self._first_block = True + + def on_content_block_start_event( + self, content_block: ContentBlock, index: int + ) -> None: + """Handle RawContentBlockStartEvent.""" + if isinstance(content_block, ToolUseBlock): + self.on_tool_use_block( + content_block.id, + content_block.input, + content_block.name, + content_block.caller, + ) + return + if isinstance(content_block, TextBlock): + self.on_text_block(content_block.text, content_block.citations) + return + if isinstance(content_block, ThinkingBlock): + self.on_thinking_block(content_block.thinking, content_block.signature) + return + if isinstance(content_block, RedactedThinkingBlock): + self.on_redacted_thinking_block(content_block.data) + return + if isinstance(content_block, ServerToolUseBlock): + self.on_server_tool_use_block( + content_block.id, + content_block.name, + content_block.input, + content_block.caller, + ) + return + if isinstance( + content_block, + ( + WebSearchToolResultBlock, + CodeExecutionToolResultBlock, + BashCodeExecutionToolResultBlock, + TextEditorCodeExecutionToolResultBlock, + ToolSearchToolResultBlock, + ), + ): + self.on_server_tool_result_block( + content_block.tool_use_id, + content_block.type, + content_block.content, + content_block.caller if hasattr(content_block, "caller") else None, + ) + return + LOGGER.debug("Unhandled content block type: %s", content_block.type) + + def on_tool_use_block( + self, id: str, input: dict[str, Any], name: str, caller: Caller | None + ) -> None: + """Handle ToolUseBlock.""" + self._current_tool_block = ToolUseBlockParam( + type="tool_use", + id=id, + name=name, + input=input, + ) + self._current_tool_args = "" + if name == self._output_tool: + if self._first_block or self._content_details.has_content(): + if self._content_details: + self._content_details.delete_empty() + self._buffer.append({"native": self._content_details}) + self._content_details = ContentDetails() + self._content_details.add_citation_detail() + self._buffer.append({"role": "assistant"}) + self._first_block = False + + def on_text_block(self, text: str, citations: list[TextCitation] | None) -> None: + """Handle TextBlock.""" + if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead. + self._first_block + or ( + not self._content_details.has_citations() + and citations is None + and self._content_details.has_content() + ) + ): + if self._content_details: + self._content_details.delete_empty() + self._buffer.append({"native": self._content_details}) + self._content_details = ContentDetails() + self._buffer.append({"role": "assistant"}) + self._first_block = False + self._content_details.add_citation_detail() + if text: + self._content_details.citation_details[-1].length += len(text) + self._buffer.append({"content": text}) + + def on_thinking_block(self, thinking: str, signature: str) -> None: + """Handle ThinkingBlock.""" + if self._first_block or self._content_details.thinking_signature: + if self._content_details: + self._content_details.delete_empty() + self._buffer.append({"native": self._content_details}) + self._content_details = ContentDetails() + self._content_details.add_citation_detail() + self._buffer.append({"role": "assistant"}) + self._first_block = False + + def on_redacted_thinking_block(self, data: str) -> None: + """Handle RedactedThinkingBlock.""" + LOGGER.debug( + "Some of Claude’s internal reasoning has been automatically " + "encrypted for safety reasons. This doesn’t affect the quality of " + "responses" + ) + if self._first_block or self._content_details.redacted_thinking: + if self._content_details: + self._content_details.delete_empty() + self._buffer.append({"native": self._content_details}) + self._content_details = ContentDetails() + self._content_details.add_citation_detail() + self._buffer.append({"role": "assistant"}) + self._first_block = False + self._content_details.redacted_thinking = data + + def on_server_tool_use_block( + self, + id: str, + name: Literal[ + "web_search", + "web_fetch", + "code_execution", + "bash_code_execution", + "text_editor_code_execution", + "tool_search_tool_regex", + "tool_search_tool_bm25", + ], + input: dict[str, Any], + caller: Caller | None, + ) -> None: + """Handle ServerToolUseBlock.""" + self._current_tool_block = ServerToolUseBlockParam( + type="server_tool_use", + id=id, + name=name, + input=input, + ) + self._current_tool_args = "" + + def on_server_tool_result_block( + self, + tool_use_id: str, + tool_name: Literal[ + "web_search_tool_result", + "code_execution_tool_result", + "bash_code_execution_tool_result", + "text_editor_code_execution_tool_result", + "tool_search_tool_result", + ], + content: WebSearchToolResultBlockContent + | CodeExecutionToolResultBlockContent + | BashCodeExecutionToolResultBlockContent + | TextEditorCodeExecutionToolResultBlockContent + | ToolSearchToolResultBlockContent, + caller: Caller | None, + ) -> None: + """Handle various server tool result blocks.""" + if self._content_details: + self._content_details.delete_empty() + self._buffer.append({"native": self._content_details}) + self._content_details = ContentDetails() + self._content_details.add_citation_detail() + self._buffer.append( + { + "role": "tool_result", + "tool_call_id": tool_use_id, + "tool_name": tool_name.removesuffix("_tool_result"), + "tool_result": { + "content": cast(JsonArrayType, [x.to_dict() for x in content]) } - first_block = True - elif isinstance(response, RawContentBlockDeltaEvent): - if isinstance(response.delta, InputJSONDelta): - if ( - current_tool_block is not None - and current_tool_block["name"] == output_tool - ): - content_details.citation_details[-1].length += len( - response.delta.partial_json - ) - yield {"content": response.delta.partial_json} - else: - current_tool_args += response.delta.partial_json - elif isinstance(response.delta, TextDelta): - if response.delta.text: - content_details.citation_details[-1].length += len( - response.delta.text - ) - yield {"content": response.delta.text} - elif isinstance(response.delta, ThinkingDelta): - if response.delta.thinking: - yield {"thinking_content": response.delta.thinking} - elif isinstance(response.delta, SignatureDelta): - content_details.thinking_signature = response.delta.signature - elif isinstance(response.delta, CitationsDelta): - content_details.add_citation(response.delta.citation) - elif isinstance(response, RawContentBlockStopEvent): - if current_tool_block is not None: - if current_tool_block["name"] == output_tool: - current_tool_block = None - continue - tool_args = json.loads(current_tool_args) if current_tool_args else {} - current_tool_block["input"] = tool_args - yield { + if isinstance(content, list) + else cast(JsonObjectType, content.to_dict()), + } + ) + self._first_block = True + + def on_content_block_delta_event(self, delta: RawContentBlockDelta) -> None: + """Handle RawContentBlockDeltaEvent.""" + if isinstance(delta, InputJSONDelta): + self.on_input_json_delta(delta.partial_json) + return + if isinstance(delta, TextDelta): + self.on_text_delta(delta.text) + return + if isinstance(delta, ThinkingDelta): + self.on_thinking_delta(delta.thinking) + return + if isinstance(delta, SignatureDelta): + self.on_signature_delta(delta.signature) + return + if isinstance(delta, CitationsDelta): + self.on_citations_delta(delta.citation) + return + LOGGER.debug("Unhandled content delta type: %s", delta.type) # type: ignore[unreachable] # pragma: no cover - All types are handled but we want to verify that + + def on_input_json_delta(self, partial_json: str) -> None: + """Handle InputJSONDelta.""" + if ( + self._current_tool_block is not None + and self._current_tool_block["name"] == self._output_tool + ): + self._content_details.citation_details[-1].length += len(partial_json) + self._buffer.append({"content": partial_json}) + else: + self._current_tool_args += partial_json + + def on_text_delta(self, text: str) -> None: + """Handle TextDelta.""" + if text: + self._content_details.citation_details[-1].length += len(text) + self._buffer.append({"content": text}) + + def on_thinking_delta(self, thinking: str) -> None: + """Handle ThinkingDelta.""" + if thinking: + self._buffer.append({"thinking_content": thinking}) + + def on_signature_delta(self, signature: str) -> None: + """Handle SignatureDelta.""" + self._content_details.thinking_signature = signature + + def on_citations_delta(self, citation: TextCitation) -> None: + """Handle CitationsDelta.""" + self._content_details.add_citation(citation) + + def on_content_block_stop_event(self, index: int) -> None: + """Handle RawContentBlockStopEvent.""" + if self._current_tool_block is not None: + if self._current_tool_block["name"] == self._output_tool: + self._current_tool_block = None + return + tool_args = ( + json.loads(self._current_tool_args) if self._current_tool_args else {} + ) + self._current_tool_block["input"] |= tool_args + self._buffer.append( + { "tool_calls": [ llm.ToolInput( - id=current_tool_block["id"], - tool_name=current_tool_block["name"], - tool_args=tool_args, - external=current_tool_block["type"] == "server_tool_use", + id=self._current_tool_block["id"], + tool_name=self._current_tool_block["name"], + tool_args=self._current_tool_block["input"], + external=self._current_tool_block["type"] + == "server_tool_use", ) ] } - current_tool_block = None - elif isinstance(response, RawMessageDeltaEvent): - if (usage := response.usage) is not None: - chat_log.async_trace(_create_token_stats(input_usage, usage)) - content_details.container = response.delta.container - if response.delta.stop_reason == "refusal": - raise HomeAssistantError( - translation_domain=DOMAIN, translation_key="api_refusal" - ) - elif isinstance(response, RawMessageStopEvent): - if content_details: - content_details.delete_empty() - yield {"native": content_details} - content_details = ContentDetails() - content_details.add_citation_detail() + ) + self._current_tool_block = None + def on_message_delta_event(self, delta: Delta, usage: MessageDeltaUsage) -> None: + """Handle RawMessageDeltaEvent.""" + self._chat_log.async_trace(self._create_token_stats(self._input_usage, usage)) + self._content_details.container = delta.container + if delta.stop_reason == "refusal": + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_refusal" + ) -def _create_token_stats( - input_usage: Usage | None, response_usage: MessageDeltaUsage -) -> dict[str, Any]: - """Create token stats for conversation agent tracing.""" - input_tokens = 0 - cached_input_tokens = 0 - if input_usage: - input_tokens = input_usage.input_tokens - cached_input_tokens = input_usage.cache_creation_input_tokens or 0 - output_tokens = response_usage.output_tokens - return { - "stats": { - "input_tokens": input_tokens, - "cached_input_tokens": cached_input_tokens, - "output_tokens": output_tokens, + def on_message_stop_event(self) -> None: + """Handle RawMessageStopEvent.""" + if self._content_details: + self._content_details.delete_empty() + self._buffer.append({"native": self._content_details}) + self._content_details = ContentDetails() + self._content_details.add_citation_detail() + + def _create_token_stats( + self, input_usage: Usage | None, response_usage: MessageDeltaUsage + ) -> dict[str, Any]: + """Create token stats for conversation agent tracing.""" + input_tokens = 0 + cached_input_tokens = 0 + if input_usage: + input_tokens = input_usage.input_tokens + cached_input_tokens = input_usage.cache_creation_input_tokens or 0 + output_tokens = response_usage.output_tokens + return { + "stats": { + "input_tokens": input_tokens, + "cached_input_tokens": cached_input_tokens, + "output_tokens": output_tokens, + } } - } -class AnthropicBaseLLMEntity(Entity): +class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]): """Anthropic base LLM entity.""" _attr_has_entity_name = True @@ -649,26 +872,39 @@ class AnthropicBaseLLMEntity(Entity): def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the entity.""" + super().__init__(entry.runtime_data) self.entry = entry self.subentry = subentry + coordinator = entry.runtime_data + self.model_info, _ = coordinator.get_model_info( + subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]) + ) self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="Anthropic", - model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]), + model=self.model_info.display_name, + model_id=self.model_info.id, entry_type=dr.DeviceEntryType.SERVICE, ) - async def _async_handle_chat_log( + async def _get_model_args( # noqa: C901 self, chat_log: conversation.ChatLog, structure_name: str | None = None, structure: vol.Schema | None = None, - max_iterations: int = MAX_TOOL_ITERATIONS, - ) -> None: - """Generate an answer for the chat log.""" - options = self.subentry.data + ) -> tuple[MessageCreateParamsStreaming, str | None]: + """Get the model arguments.""" + options: dict[str, Any] = DEFAULT | self.subentry.data + + preloaded_tools = [ + "HassTurnOn", + "HassTurnOff", + "GetLiveContext", + "code_execution", + "web_search", + ] system = chat_log.content[0] if not isinstance(system, conversation.SystemContent): @@ -676,55 +912,61 @@ class AnthropicBaseLLMEntity(Entity): translation_domain=DOMAIN, translation_key="system_message_not_found" ) - # System prompt with caching enabled - system_prompt: list[TextBlockParam] = [ - TextBlockParam( - type="text", - text=system.content, - cache_control={"type": "ephemeral"}, - ) - ] - messages, container_id = _convert_content(chat_log.content[1:]) - model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]) + model = options[CONF_CHAT_MODEL] model_args = MessageCreateParamsStreaming( model=model, messages=messages, - max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]), - system=system_prompt, + max_tokens=options[CONF_MAX_TOKENS], + system=system.content, stream=True, container=container_id, ) - if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)): - thinking_effort = options.get( - CONF_THINKING_EFFORT, DEFAULT[CONF_THINKING_EFFORT] - ) + if options[CONF_PROMPT_CACHING] == PromptCaching.PROMPT: + model_args["system"] = [ + { + "type": "text", + "text": system.content, + "cache_control": {"type": "ephemeral"}, + } + ] + elif options[CONF_PROMPT_CACHING] == PromptCaching.AUTOMATIC: + model_args["cache_control"] = {"type": "ephemeral"} + + if ( + self.model_info.capabilities + and self.model_info.capabilities.thinking.types.adaptive.supported + ): + thinking_effort = options[CONF_THINKING_EFFORT] if thinking_effort != "none": - model_args["thinking"] = ThinkingConfigAdaptiveParam(type="adaptive") + model_args["thinking"] = ThinkingConfigAdaptiveParam( + type="adaptive", display="summarized" + ) model_args["output_config"] = OutputConfigParam(effort=thinking_effort) else: model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") - model_args["temperature"] = options.get( - CONF_TEMPERATURE, DEFAULT[CONF_TEMPERATURE] - ) else: - thinking_budget = options.get( - CONF_THINKING_BUDGET, DEFAULT[CONF_THINKING_BUDGET] - ) + thinking_budget = options[CONF_THINKING_BUDGET] if ( - not model.startswith(tuple(NON_THINKING_MODELS)) + self.model_info.capabilities + and self.model_info.capabilities.thinking.types.enabled.supported and thinking_budget >= MIN_THINKING_BUDGET ): model_args["thinking"] = ThinkingConfigEnabledParam( - type="enabled", budget_tokens=thinking_budget + type="enabled", display="summarized", budget_tokens=thinking_budget ) else: model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") - model_args["temperature"] = options.get( - CONF_TEMPERATURE, DEFAULT[CONF_TEMPERATURE] + + if ( + self.model_info.capabilities + and self.model_info.capabilities.effort.supported + ): + model_args["output_config"] = OutputConfigParam( + effort=options[CONF_THINKING_EFFORT] ) tools: list[ToolUnionParam] = [] @@ -734,21 +976,40 @@ class AnthropicBaseLLMEntity(Entity): for tool in chat_log.llm_api.tools ] - if options.get(CONF_CODE_EXECUTION): - tools.append( - CodeExecutionTool20250825Param( - name="code_execution", - type="code_execution_20250825", - ), - ) + if options[CONF_CODE_EXECUTION]: + # The `web_search_20260209` tool automatically enables `code_execution_20260120` tool + if ( + not self.model_info.capabilities + or not self.model_info.capabilities.code_execution.supported + or not options[CONF_WEB_SEARCH] + ): + tools.append( + CodeExecutionTool20250825Param( + name="code_execution", + type="code_execution_20250825", + ), + ) - if options.get(CONF_WEB_SEARCH): - web_search = WebSearchTool20250305Param( - name="web_search", - type="web_search_20250305", - max_uses=options.get(CONF_WEB_SEARCH_MAX_USES), - ) - if options.get(CONF_WEB_SEARCH_USER_LOCATION): + if options[CONF_WEB_SEARCH]: + if ( + not self.model_info.capabilities + or not self.model_info.capabilities.code_execution.supported + or not options[CONF_CODE_EXECUTION] + ): + web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = ( + WebSearchTool20250305Param( + name="web_search", + type="web_search_20250305", + max_uses=options[CONF_WEB_SEARCH_MAX_USES], + ) + ) + else: + web_search = WebSearchTool20260209Param( + name="web_search", + type="web_search_20260209", + max_uses=options[CONF_WEB_SEARCH_MAX_USES], + ) + if options[CONF_WEB_SEARCH_USER_LOCATION]: web_search["user_location"] = { "type": "approximate", "city": options.get(CONF_WEB_SEARCH_CITY, ""), @@ -772,12 +1033,17 @@ class AnthropicBaseLLMEntity(Entity): ] last_message["content"].extend( # type: ignore[union-attr] await async_prepare_files_for_prompt( - self.hass, [(a.path, a.mime_type) for a in last_content.attachments] + self.hass, + self.model_info, + [(a.path, a.mime_type) for a in last_content.attachments], ) ) if structure and structure_name: - if not model.startswith(tuple(UNSUPPORTED_STRUCTURED_OUTPUT_MODELS)): + if ( + self.model_info.capabilities + and self.model_info.capabilities.structured_outputs.supported + ): # Native structured output for those models who support it. structure_name = None model_args.setdefault("output_config", OutputConfigParam())[ @@ -841,11 +1107,37 @@ class AnthropicBaseLLMEntity(Entity): ), ) ) + preloaded_tools.append(structure_name) if tools: + if options[CONF_TOOL_SEARCH] and len(tools) > len(preloaded_tools) + 1: + for tool in tools: + if not tool["name"].endswith(tuple(preloaded_tools)): + tool["defer_loading"] = True + tools.append( + ToolSearchToolBm25_20251119Param( + type="tool_search_tool_bm25_20251119", + name="tool_search_tool_bm25", + ) + ) + model_args["tools"] = tools - client = self.entry.runtime_data + return model_args, structure_name + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + structure_name: str | None = None, + structure: vol.Schema | None = None, + max_iterations: int = MAX_TOOL_ITERATIONS, + ) -> None: + """Generate an answer for the chat log.""" + model_args, structure_name = await self._get_model_args( + chat_log, structure_name, structure + ) + coordinator = self.entry.runtime_data + client = coordinator.client # To prevent infinite loops, we limit the number of iterations for _iteration in range(max_iterations): @@ -857,7 +1149,7 @@ class AnthropicBaseLLMEntity(Entity): content async for content in chat_log.async_add_delta_content_stream( self.entity_id, - _transform_stream( + AnthropicDeltaStream( chat_log, stream, output_tool=structure_name or None, @@ -865,15 +1157,27 @@ class AnthropicBaseLLMEntity(Entity): ) ] ) - messages.extend(new_messages) + cast(list[MessageParam], model_args["messages"]).extend(new_messages) except anthropic.AuthenticationError as err: - self.entry.async_start_reauth(self.hass) + # Trigger coordinator to confirm the auth failure and trigger the reauth flow. + await coordinator.async_request_refresh() raise HomeAssistantError( translation_domain=DOMAIN, translation_key="api_authentication_error", translation_placeholders={"message": err.message}, ) from err + except anthropic.APIConnectionError as err: + LOGGER.info("Connection error while talking to Anthropic: %s", err) + coordinator.mark_connection_error() + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": err.message}, + ) from err except anthropic.AnthropicError as err: + # Non-connection error, mark connection as healthy + coordinator.async_set_updated_data(coordinator.data) + LOGGER.error("Error while talking to Anthropic: %s", err) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="api_error", @@ -885,11 +1189,12 @@ class AnthropicBaseLLMEntity(Entity): ) from err if not chat_log.unresponded_tool_results: + coordinator.async_set_updated_data(coordinator.data) break async def async_prepare_files_for_prompt( - hass: HomeAssistant, files: list[tuple[Path, str | None]] + hass: HomeAssistant, model_info: ModelInfo, files: list[tuple[Path, str | None]] ) -> Iterable[ImageBlockParam | DocumentBlockParam]: """Append files to a prompt. @@ -910,13 +1215,26 @@ async def async_prepare_files_for_prompt( if mime_type is None: mime_type = guess_file_type(file_path)[0] - if not mime_type or not mime_type.startswith(("image/", "application/pdf")): + if ( + not mime_type + or not mime_type.startswith(("image/", "application/pdf")) + or not model_info.capabilities + or ( + mime_type.startswith("image/") + and not model_info.capabilities.image_input.supported + ) + or ( + mime_type.startswith("application/pdf") + and not model_info.capabilities.pdf_input.supported + ) + ): raise HomeAssistantError( translation_domain=DOMAIN, translation_key="wrong_file_type", translation_placeholders={ "file_path": file_path.as_posix(), "mime_type": mime_type or "unknown", + "model": model_info.display_name, }, ) if mime_type == "image/jpg": diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 7ed34c517d1..7009805e9be 100644 --- a/homeassistant/components/anthropic/manifest.json +++ b/homeassistant/components/anthropic/manifest.json @@ -8,6 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/anthropic", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "bronze", - "requirements": ["anthropic==0.83.0"] + "quality_scale": "silver", + "requirements": ["anthropic==0.96.0"] } diff --git a/homeassistant/components/anthropic/quality_scale.yaml b/homeassistant/components/anthropic/quality_scale.yaml index 39eb1fae8c0..28d2c0999fc 100644 --- a/homeassistant/components/anthropic/quality_scale.yaml +++ b/homeassistant/components/anthropic/quality_scale.yaml @@ -35,9 +35,9 @@ rules: config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: status: exempt comment: | @@ -46,7 +46,7 @@ rules: test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: | @@ -61,12 +61,9 @@ rules: No data updates. docs-examples: done docs-known-limitations: done - docs-supported-devices: - status: todo - comment: | - To write something about what models we support. + docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: done dynamic-devices: status: exempt @@ -84,7 +81,10 @@ rules: status: exempt comment: | No entities disabled by default. - entity-translations: todo + entity-translations: + status: exempt + comment: | + Entities explicitly set `_attr_name` to `None`, so entity name translations are not used. exception-translations: done icon-translations: done reconfiguration-flow: done diff --git a/homeassistant/components/anthropic/repairs.py b/homeassistant/components/anthropic/repairs.py index ac78e690eba..45996c85867 100644 --- a/homeassistant/components/anthropic/repairs.py +++ b/homeassistant/components/anthropic/repairs.py @@ -1,10 +1,10 @@ """Issue repair flow for Anthropic.""" -from __future__ import annotations - from collections.abc import Iterator from typing import TYPE_CHECKING +import anthropic +from anthropic.resources.messages.messages import DEPRECATED_MODELS import voluptuous as vol from homeassistant import data_entry_flow @@ -18,8 +18,8 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, ) -from .config_flow import get_model_list -from .const import CONF_CHAT_MODEL, DEPRECATED_MODELS, DOMAIN +from .const import CONF_CHAT_MODEL, DOMAIN +from .coordinator import model_alias if TYPE_CHECKING: from . import AnthropicConfigEntry @@ -58,11 +58,11 @@ class ModelDeprecatedRepairFlow(RepairsFlow): if entry.entry_id in self._model_list_cache: model_list = self._model_list_cache[entry.entry_id] else: - client = entry.runtime_data + client = entry.runtime_data.client model_list = [ model_option - for model_option in await get_model_list(client) - if not model_option["value"].startswith(tuple(DEPRECATED_MODELS)) + for model_option in await self.get_model_list(client) + if model_option["value"] not in DEPRECATED_MODELS ] self._model_list_cache[entry.entry_id] = model_list @@ -104,9 +104,26 @@ class ModelDeprecatedRepairFlow(RepairsFlow): "model": model, "subentry_name": subentry.title, "subentry_type": self._format_subentry_type(subentry.subentry_type), + "retirement_date": DEPRECATED_MODELS[model], }, ) + async def get_model_list( + self, client: anthropic.AsyncAnthropic + ) -> list[SelectOptionDict]: + """Get list of available models.""" + try: + models = (await client.models.list(timeout=10.0)).data + except anthropic.AnthropicError: + models = [] + return [ + SelectOptionDict( + label=model_info.display_name, + value=model_alias(model_info.id), + ) + for model_info in models + ] + def _iter_deprecated_subentries(self) -> Iterator[tuple[str, str]]: """Yield entry/subentry pairs that use deprecated models.""" for entry in self.hass.config_entries.async_entries(DOMAIN): @@ -114,7 +131,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow): continue for subentry in entry.subentries.values(): model = subentry.data.get(CONF_CHAT_MODEL) - if model and model.startswith(tuple(DEPRECATED_MODELS)): + if model and model in DEPRECATED_MODELS: yield entry.entry_id, subentry.subentry_id async def _async_next_target( @@ -141,7 +158,7 @@ class ModelDeprecatedRepairFlow(RepairsFlow): continue model = subentry.data.get(CONF_CHAT_MODEL) - if not model or not model.startswith(tuple(DEPRECATED_MODELS)): + if not model or model not in DEPRECATED_MODELS: continue self._current_entry_id = entry_id diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 72b15fbe2dd..b74314bb537 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -38,6 +38,11 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "entry_type": "AI task", + "error": { + "api_error": "[%key:component::anthropic::config_subentries::conversation::error::api_error%]", + "model_not_found": "[%key:component::anthropic::config_subentries::conversation::error::model_not_found%]", + "thinking_budget_too_large": "[%key:component::anthropic::config_subentries::conversation::error::thinking_budget_too_large%]" + }, "initiate_flow": { "reconfigure": "Reconfigure AI task", "user": "Add AI task" @@ -46,12 +51,12 @@ "advanced": { "data": { "chat_model": "[%key:common::generic::model%]", - "max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::max_tokens%]", + "prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::prompt_caching%]", "temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]" }, "data_description": { "chat_model": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::chat_model%]", - "max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::max_tokens%]", + "prompt_caching": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::prompt_caching%]", "temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data_description::temperature%]" }, "title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]" @@ -70,16 +75,20 @@ "model": { "data": { "code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data::code_execution%]", + "max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::model::data::max_tokens%]", "thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_budget%]", "thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_effort%]", + "tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::tool_search%]", "user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]", "web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]", "web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]" }, "data_description": { "code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::code_execution%]", + "max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::max_tokens%]", "thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_budget%]", "thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_effort%]", + "tool_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::tool_search%]", "user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]", "web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]", "web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]" @@ -94,6 +103,11 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "entry_type": "Conversation agent", + "error": { + "api_error": "Unable to get model info: {message}", + "model_not_found": "Model not found", + "thinking_budget_too_large": "Thinking budget must be less than the Maximum tokens." + }, "initiate_flow": { "reconfigure": "Reconfigure conversation agent", "user": "Add conversation agent" @@ -102,12 +116,12 @@ "advanced": { "data": { "chat_model": "[%key:common::generic::model%]", - "max_tokens": "Maximum tokens to return in response", + "prompt_caching": "Caching strategy", "temperature": "Temperature" }, "data_description": { "chat_model": "The model to serve the responses.", - "max_tokens": "Limit the number of response tokens.", + "prompt_caching": "Optimize your API cost and response times based on your usage.", "temperature": "Control the randomness of the response, trading off between creativity and coherence." }, "title": "Advanced settings" @@ -130,16 +144,20 @@ "model": { "data": { "code_execution": "Code execution", + "max_tokens": "Maximum tokens to return in response", "thinking_budget": "Thinking budget", "thinking_effort": "Thinking effort", + "tool_search": "Enable tool search tool", "user_location": "Include home location", "web_search": "Enable web search", "web_search_max_uses": "Maximum web searches" }, "data_description": { "code_execution": "Allow the model to execute code in a secure sandbox environment, enabling it to analyze data and perform complex calculations.", + "max_tokens": "Limit the number of response tokens.", "thinking_budget": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking.", "thinking_effort": "Control how many tokens Claude uses when responding, trading off between response thoroughness and token efficiency", + "tool_search": "Enable dynamic tool discovery instead of preloading all tools into the context", "user_location": "Localize search results based on home location", "web_search": "The web search tool gives Claude direct access to real-time web content, allowing it to answer questions with up-to-date information beyond its knowledge cutoff", "web_search_max_uses": "Limit the number of searches performed per response" @@ -187,7 +205,7 @@ "message": "`{file_path}` does not exist." }, "wrong_file_type": { - "message": "Only images and PDF are supported by the Anthropic API, `{file_path}` ({mime_type}) is not an image file or PDF." + "message": "The {model} model does not support {mime_type} file types (for `{file_path}`)." } }, "issues": { @@ -201,7 +219,7 @@ "data_description": { "chat_model": "Select the new model to use." }, - "description": "You are updating {subentry_name} ({subentry_type}) in {entry_name}. The current model {model} is deprecated. Select a supported model to continue.", + "description": "You are updating {subentry_name} ({subentry_type}) in {entry_name}. The current model {model} is deprecated and will reach end-of-life on {retirement_date}. Select a supported model to continue.", "title": "Update model" } } @@ -210,13 +228,21 @@ } }, "selector": { + "prompt_caching": { + "options": { + "automatic": "Full", + "off": "Disabled", + "prompt": "System prompt" + } + }, "thinking_effort": { "options": { "high": "[%key:common::state::high%]", "low": "[%key:common::state::low%]", "max": "Max", "medium": "[%key:common::state::medium%]", - "none": "None" + "none": "None", + "xhigh": "X-High" } } } diff --git a/homeassistant/components/aosmith/__init__.py b/homeassistant/components/aosmith/__init__.py index 210993b2203..b9c52b27fa6 100644 --- a/homeassistant/components/aosmith/__init__.py +++ b/homeassistant/components/aosmith/__init__.py @@ -1,7 +1,5 @@ """The A. O. Smith integration.""" -from __future__ import annotations - from py_aosmith import AOSmithAPIClient from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform diff --git a/homeassistant/components/aosmith/config_flow.py b/homeassistant/components/aosmith/config_flow.py index a6a0712c4f7..a775beb72c2 100644 --- a/homeassistant/components/aosmith/config_flow.py +++ b/homeassistant/components/aosmith/config_flow.py @@ -1,7 +1,5 @@ """Config flow for A. O. Smith integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/aosmith/coordinator.py b/homeassistant/components/aosmith/coordinator.py index 16cacfcbc10..3253edd5be2 100644 --- a/homeassistant/components/aosmith/coordinator.py +++ b/homeassistant/components/aosmith/coordinator.py @@ -1,7 +1,5 @@ """The data update coordinator for the A. O. Smith integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/aosmith/diagnostics.py b/homeassistant/components/aosmith/diagnostics.py index 4019bee4dc8..47416df066e 100644 --- a/homeassistant/components/aosmith/diagnostics.py +++ b/homeassistant/components/aosmith/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for A. O. Smith.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/apache_kafka/__init__.py b/homeassistant/components/apache_kafka/__init__.py index 40f71ec4e4b..2303367c946 100644 --- a/homeassistant/components/apache_kafka/__init__.py +++ b/homeassistant/components/apache_kafka/__init__.py @@ -1,7 +1,5 @@ """Support for Apache Kafka.""" -from __future__ import annotations - from datetime import datetime import json from typing import Any, Literal diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 7526d605c59..b1c61673643 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -1,7 +1,5 @@ """Support for APCUPSd via its Network Information Server (NIS).""" -from __future__ import annotations - from typing import Final from homeassistant.const import CONF_HOST, CONF_PORT, Platform diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py index 394ff4c4088..4018d7d8386 100644 --- a/homeassistant/components/apcupsd/binary_sensor.py +++ b/homeassistant/components/apcupsd/binary_sensor.py @@ -1,7 +1,5 @@ """Support for tracking the online status of a UPS.""" -from __future__ import annotations - from typing import Final from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/apcupsd/config_flow.py b/homeassistant/components/apcupsd/config_flow.py index 71e60cdf01e..059eb307a1d 100644 --- a/homeassistant/components/apcupsd/config_flow.py +++ b/homeassistant/components/apcupsd/config_flow.py @@ -1,7 +1,5 @@ """Config flow for APCUPSd integration.""" -from __future__ import annotations - import asyncio from typing import Any diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py index fb9d31764cc..8298a54190f 100644 --- a/homeassistant/components/apcupsd/coordinator.py +++ b/homeassistant/components/apcupsd/coordinator.py @@ -1,7 +1,5 @@ """Support for APCUPSd via its Network Information Server (NIS).""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/apcupsd/diagnostics.py b/homeassistant/components/apcupsd/diagnostics.py index a4bbf2191d2..5aded38a4da 100644 --- a/homeassistant/components/apcupsd/diagnostics.py +++ b/homeassistant/components/apcupsd/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for APCUPSD.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/apcupsd/entity.py b/homeassistant/components/apcupsd/entity.py index 9ebe51ff876..a7f0f2b7204 100644 --- a/homeassistant/components/apcupsd/entity.py +++ b/homeassistant/components/apcupsd/entity.py @@ -1,7 +1,5 @@ """Base entity for APCUPSd integration.""" -from __future__ import annotations - from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 85241ac7432..978e2083ac8 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -1,7 +1,5 @@ """Support for APCUPSd sensors.""" -from __future__ import annotations - import logging import dateutil diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index 09b11f555cf..fed1118fe02 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -1,7 +1,5 @@ """The Apple TV integration.""" -from __future__ import annotations - import asyncio import logging from random import randrange @@ -30,9 +28,10 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_CREDENTIALS, @@ -42,9 +41,12 @@ from .const import ( SIGNAL_CONNECTED, SIGNAL_DISCONNECTED, ) +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + DEFAULT_NAME_TV = "Apple TV" DEFAULT_NAME_HP = "HomePod" @@ -77,6 +79,12 @@ DEVICE_EXCEPTIONS = ( type AppleTvConfigEntry = ConfigEntry[AppleTVManager] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Apple TV component.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: AppleTvConfigEntry) -> bool: """Set up a config entry for Apple TV.""" manager = AppleTVManager(hass, entry) diff --git a/homeassistant/components/apple_tv/binary_sensor.py b/homeassistant/components/apple_tv/binary_sensor.py index 3bbd46083fc..65ad1484816 100644 --- a/homeassistant/components/apple_tv/binary_sensor.py +++ b/homeassistant/components/apple_tv/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor support for Apple TV.""" -from __future__ import annotations - from pyatv.const import FeatureName, FeatureState, KeyboardFocusState from pyatv.interface import AppleTV, KeyboardListener @@ -14,6 +12,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SIGNAL_CONNECTED, AppleTvConfigEntry from .entity import AppleTVEntity +PARALLEL_UPDATES = 0 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index b026da33231..18fa2a2f815 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Apple TV integration.""" -from __future__ import annotations - import asyncio from collections import deque from collections.abc import Awaitable, Callable, Mapping diff --git a/homeassistant/components/apple_tv/const.py b/homeassistant/components/apple_tv/const.py index dd215337f1c..eefd90eef64 100644 --- a/homeassistant/components/apple_tv/const.py +++ b/homeassistant/components/apple_tv/const.py @@ -9,3 +9,5 @@ CONF_START_OFF = "start_off" SIGNAL_CONNECTED = "apple_tv_connected" SIGNAL_DISCONNECTED = "apple_tv_disconnected" + +ATTR_TEXT = "text" diff --git a/homeassistant/components/apple_tv/entity.py b/homeassistant/components/apple_tv/entity.py index 30f8a1ed939..4394614d99e 100644 --- a/homeassistant/components/apple_tv/entity.py +++ b/homeassistant/components/apple_tv/entity.py @@ -1,7 +1,5 @@ """The Apple TV integration.""" -from __future__ import annotations - from pyatv.interface import AppleTV as AppleTVInterface from homeassistant.core import callback diff --git a/homeassistant/components/apple_tv/icons.json b/homeassistant/components/apple_tv/icons.json index 8acb855e3c7..96aec31cc77 100644 --- a/homeassistant/components/apple_tv/icons.json +++ b/homeassistant/components/apple_tv/icons.json @@ -8,5 +8,16 @@ } } } + }, + "services": { + "append_keyboard_text": { + "service": "mdi:keyboard" + }, + "clear_keyboard_text": { + "service": "mdi:keyboard-off" + }, + "set_keyboard_text": { + "service": "mdi:keyboard" + } } } diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index aa3be980625..9a2c22351f7 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -1,7 +1,5 @@ """Support for Apple TV media player.""" -from __future__ import annotations - from datetime import datetime import logging from typing import Any diff --git a/homeassistant/components/apple_tv/services.py b/homeassistant/components/apple_tv/services.py new file mode 100644 index 00000000000..8c45821f0b6 --- /dev/null +++ b/homeassistant/components/apple_tv/services.py @@ -0,0 +1,128 @@ +"""Define services for the Apple TV integration.""" + +from pyatv.const import KeyboardFocusState +from pyatv.exceptions import NotSupportedError, ProtocolError +from pyatv.interface import AppleTV as AppleTVInterface +import voluptuous as vol + +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, service + +from .const import ATTR_TEXT, DOMAIN + +SERVICE_SET_KEYBOARD_TEXT = "set_keyboard_text" +SERVICE_SET_KEYBOARD_TEXT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_TEXT): cv.string, + } +) + +SERVICE_APPEND_KEYBOARD_TEXT = "append_keyboard_text" +SERVICE_APPEND_KEYBOARD_TEXT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_TEXT): cv.string, + } +) + +SERVICE_CLEAR_KEYBOARD_TEXT = "clear_keyboard_text" +SERVICE_CLEAR_KEYBOARD_TEXT_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, + } +) + + +def _get_atv(call: ServiceCall) -> AppleTVInterface: + """Get the AppleTVInterface for a service call.""" + entry = service.async_get_config_entry( + call.hass, DOMAIN, call.data[ATTR_CONFIG_ENTRY_ID] + ) + atv: AppleTVInterface | None = entry.runtime_data.atv + if atv is None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="not_connected", + ) + return atv + + +def _check_keyboard_focus(atv: AppleTVInterface) -> None: + """Check that keyboard is focused on the device.""" + try: + focus_state = atv.keyboard.text_focus_state + except NotSupportedError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="keyboard_not_available", + ) from err + if focus_state != KeyboardFocusState.Focused: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="keyboard_not_focused", + ) + + +async def _async_set_keyboard_text(call: ServiceCall) -> None: + """Set text in the keyboard input field on an Apple TV.""" + atv = _get_atv(call) + _check_keyboard_focus(atv) + try: + await atv.keyboard.text_set(call.data[ATTR_TEXT]) + except ProtocolError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="keyboard_error", + ) from err + + +async def _async_append_keyboard_text(call: ServiceCall) -> None: + """Append text to the keyboard input field on an Apple TV.""" + atv = _get_atv(call) + _check_keyboard_focus(atv) + try: + await atv.keyboard.text_append(call.data[ATTR_TEXT]) + except ProtocolError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="keyboard_error", + ) from err + + +async def _async_clear_keyboard_text(call: ServiceCall) -> None: + """Clear text in the keyboard input field on an Apple TV.""" + atv = _get_atv(call) + _check_keyboard_focus(atv) + try: + await atv.keyboard.text_clear() + except ProtocolError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="keyboard_error", + ) from err + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Apple TV integration.""" + hass.services.async_register( + DOMAIN, + SERVICE_SET_KEYBOARD_TEXT, + _async_set_keyboard_text, + schema=SERVICE_SET_KEYBOARD_TEXT_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_APPEND_KEYBOARD_TEXT, + _async_append_keyboard_text, + schema=SERVICE_APPEND_KEYBOARD_TEXT_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_CLEAR_KEYBOARD_TEXT, + _async_clear_keyboard_text, + schema=SERVICE_CLEAR_KEYBOARD_TEXT_SCHEMA, + ) diff --git a/homeassistant/components/apple_tv/services.yaml b/homeassistant/components/apple_tv/services.yaml new file mode 100644 index 00000000000..ce2914e4d0e --- /dev/null +++ b/homeassistant/components/apple_tv/services.yaml @@ -0,0 +1,31 @@ +set_keyboard_text: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: apple_tv + text: + required: true + selector: + text: + +append_keyboard_text: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: apple_tv + text: + required: true + selector: + text: + +clear_keyboard_text: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: apple_tv diff --git a/homeassistant/components/apple_tv/strings.json b/homeassistant/components/apple_tv/strings.json index 98ff4b9acb7..c8da75fb1e2 100644 --- a/homeassistant/components/apple_tv/strings.json +++ b/homeassistant/components/apple_tv/strings.json @@ -69,6 +69,20 @@ } } }, + "exceptions": { + "keyboard_error": { + "message": "An error occurred while sending text to the Apple TV" + }, + "keyboard_not_available": { + "message": "Keyboard input is not supported by this device" + }, + "keyboard_not_focused": { + "message": "No text input field is currently focused on the Apple TV" + }, + "not_connected": { + "message": "Apple TV is not connected" + } + }, "options": { "step": { "init": { @@ -78,5 +92,45 @@ "description": "Configure general device settings" } } + }, + "services": { + "append_keyboard_text": { + "description": "Appends text to the currently focused text input field on an Apple TV.", + "fields": { + "config_entry_id": { + "description": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::description%]", + "name": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::name%]" + }, + "text": { + "description": "The text to append.", + "name": "[%key:component::apple_tv::services::set_keyboard_text::fields::text::name%]" + } + }, + "name": "Append keyboard text" + }, + "clear_keyboard_text": { + "description": "Clears the text in the currently focused text input field on an Apple TV.", + "fields": { + "config_entry_id": { + "description": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::description%]", + "name": "[%key:component::apple_tv::services::set_keyboard_text::fields::config_entry_id::name%]" + } + }, + "name": "Clear keyboard text" + }, + "set_keyboard_text": { + "description": "Sets the text in the currently focused text input field on an Apple TV.", + "fields": { + "config_entry_id": { + "description": "The Apple TV to send text to.", + "name": "Apple TV" + }, + "text": { + "description": "The text to set.", + "name": "Text" + } + }, + "name": "Set keyboard text" + } } } diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index ac0e92b3714..6278a5e2d83 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -6,8 +6,6 @@ the APIs are used to add one or more client credentials. Integrations may also provide credentials from yaml for backwards compatibility. """ -from __future__ import annotations - from dataclasses import dataclass import logging from typing import Any, Protocol @@ -155,7 +153,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_COMPONENT] = storage_collection collection.DictStorageCollectionWebsocket( - storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS + storage_collection, + DOMAIN, + DOMAIN, + CREATE_FIELDS, + UPDATE_FIELDS, + admin_only=True, ).async_setup(hass) websocket_api.async_register_command(hass, handle_integration_list) @@ -341,6 +344,7 @@ async def handle_integration_list( vol.Required("config_entry_id"): str, } ) +@websocket_api.require_admin @websocket_api.async_response async def handle_config_entry( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] diff --git a/homeassistant/components/apprise/notify.py b/homeassistant/components/apprise/notify.py index a2efcb577d3..15a2fb7a9e7 100644 --- a/homeassistant/components/apprise/notify.py +++ b/homeassistant/components/apprise/notify.py @@ -1,7 +1,5 @@ """Apprise platform for notify component.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/aprilaire/__init__.py b/homeassistant/components/aprilaire/__init__.py index 90293798ed3..181969e2f2e 100644 --- a/homeassistant/components/aprilaire/__init__.py +++ b/homeassistant/components/aprilaire/__init__.py @@ -1,7 +1,5 @@ """The Aprilaire integration.""" -from __future__ import annotations - import logging from pyaprilaire.const import Attribute diff --git a/homeassistant/components/aprilaire/climate.py b/homeassistant/components/aprilaire/climate.py index bc8226c65a6..b4c42617da5 100644 --- a/homeassistant/components/aprilaire/climate.py +++ b/homeassistant/components/aprilaire/climate.py @@ -1,7 +1,5 @@ """The Aprilaire climate component.""" -from __future__ import annotations - from typing import Any from pyaprilaire.const import Attribute diff --git a/homeassistant/components/aprilaire/config_flow.py b/homeassistant/components/aprilaire/config_flow.py index 0b4f9af3401..f985e93a1dc 100644 --- a/homeassistant/components/aprilaire/config_flow.py +++ b/homeassistant/components/aprilaire/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Aprilaire integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/aprilaire/const.py b/homeassistant/components/aprilaire/const.py index baf92294266..4276bf81654 100644 --- a/homeassistant/components/aprilaire/const.py +++ b/homeassistant/components/aprilaire/const.py @@ -1,7 +1,5 @@ """Constants for the Aprilaire integration.""" -from __future__ import annotations - DOMAIN = "aprilaire" FAN_CIRCULATE = "Circulate" diff --git a/homeassistant/components/aprilaire/coordinator.py b/homeassistant/components/aprilaire/coordinator.py index 287b3c0a4aa..ede46171225 100644 --- a/homeassistant/components/aprilaire/coordinator.py +++ b/homeassistant/components/aprilaire/coordinator.py @@ -1,7 +1,5 @@ """The Aprilaire coordinator.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable import logging from typing import Any diff --git a/homeassistant/components/aprilaire/entity.py b/homeassistant/components/aprilaire/entity.py index e2f2bf109ef..51ba5e0c38c 100644 --- a/homeassistant/components/aprilaire/entity.py +++ b/homeassistant/components/aprilaire/entity.py @@ -1,7 +1,5 @@ """Base functionality for Aprilaire entities.""" -from __future__ import annotations - import logging from pyaprilaire.const import Attribute diff --git a/homeassistant/components/aprilaire/humidifier.py b/homeassistant/components/aprilaire/humidifier.py index a58f8c43001..0e5ec12058e 100644 --- a/homeassistant/components/aprilaire/humidifier.py +++ b/homeassistant/components/aprilaire/humidifier.py @@ -1,7 +1,5 @@ """The Aprilaire humidifier component.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any, cast diff --git a/homeassistant/components/aprilaire/select.py b/homeassistant/components/aprilaire/select.py index c38c9e94501..a6d50f56b3e 100644 --- a/homeassistant/components/aprilaire/select.py +++ b/homeassistant/components/aprilaire/select.py @@ -1,7 +1,5 @@ """The Aprilaire select component.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import cast diff --git a/homeassistant/components/aprilaire/sensor.py b/homeassistant/components/aprilaire/sensor.py index bf3bd12f43d..e3380275dc9 100644 --- a/homeassistant/components/aprilaire/sensor.py +++ b/homeassistant/components/aprilaire/sensor.py @@ -1,7 +1,5 @@ """The Aprilaire sensor component.""" -from __future__ import annotations - from dataclasses import dataclass from typing import cast diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index fc3dbcabfe8..57f33a9db0c 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -1,7 +1,5 @@ """Support for APRS device tracking.""" -from __future__ import annotations - import logging import threading from typing import Any diff --git a/homeassistant/components/apsystems/__init__.py b/homeassistant/components/apsystems/__init__.py index cdc4563b92d..69814c8deea 100644 --- a/homeassistant/components/apsystems/__init__.py +++ b/homeassistant/components/apsystems/__init__.py @@ -1,7 +1,5 @@ """The APsystems local API integration.""" -from __future__ import annotations - from APsystemsEZ1 import APsystemsEZ1M from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform diff --git a/homeassistant/components/apsystems/binary_sensor.py b/homeassistant/components/apsystems/binary_sensor.py index 202d878014d..8adad427b27 100644 --- a/homeassistant/components/apsystems/binary_sensor.py +++ b/homeassistant/components/apsystems/binary_sensor.py @@ -1,7 +1,5 @@ """The read-only binary sensors for APsystems local API integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py index 331af8f290f..ae0b87925d3 100644 --- a/homeassistant/components/apsystems/coordinator.py +++ b/homeassistant/components/apsystems/coordinator.py @@ -1,7 +1,5 @@ """The coordinator for APsystems local API integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/apsystems/entity.py b/homeassistant/components/apsystems/entity.py index 2ce8becbf80..335bdc7b2d8 100644 --- a/homeassistant/components/apsystems/entity.py +++ b/homeassistant/components/apsystems/entity.py @@ -1,7 +1,5 @@ """APsystems base entity.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/apsystems/number.py b/homeassistant/components/apsystems/number.py index 5b13a50213c..f819a7da423 100644 --- a/homeassistant/components/apsystems/number.py +++ b/homeassistant/components/apsystems/number.py @@ -1,7 +1,5 @@ """The output limit which can be set in the APsystems local API integration.""" -from __future__ import annotations - from aiohttp import ClientConnectorError from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode diff --git a/homeassistant/components/apsystems/sensor.py b/homeassistant/components/apsystems/sensor.py index 6e654cfbf61..22ec1f03efc 100644 --- a/homeassistant/components/apsystems/sensor.py +++ b/homeassistant/components/apsystems/sensor.py @@ -1,7 +1,5 @@ """The read-only sensors for APsystems local API integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/apsystems/switch.py b/homeassistant/components/apsystems/switch.py index 73c0da7abf2..35d8990b4b9 100644 --- a/homeassistant/components/apsystems/switch.py +++ b/homeassistant/components/apsystems/switch.py @@ -1,7 +1,5 @@ """The power switch which can be toggled via the APsystems local API integration.""" -from __future__ import annotations - from typing import Any from aiohttp.client_exceptions import ClientConnectionError diff --git a/homeassistant/components/aquacell/__init__.py b/homeassistant/components/aquacell/__init__.py index 60787840fcb..1b213f7e1ad 100644 --- a/homeassistant/components/aquacell/__init__.py +++ b/homeassistant/components/aquacell/__init__.py @@ -1,7 +1,5 @@ """The Aquacell integration.""" -from __future__ import annotations - from aioaquacell import AquacellApi from aioaquacell.const import Brand diff --git a/homeassistant/components/aquacell/config_flow.py b/homeassistant/components/aquacell/config_flow.py index 5f62febf441..444abb9ab2b 100644 --- a/homeassistant/components/aquacell/config_flow.py +++ b/homeassistant/components/aquacell/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Aquacell integration.""" -from __future__ import annotations - from datetime import datetime import logging from typing import Any diff --git a/homeassistant/components/aquacell/entity.py b/homeassistant/components/aquacell/entity.py index 6c746ded24c..8d4fea5d39d 100644 --- a/homeassistant/components/aquacell/entity.py +++ b/homeassistant/components/aquacell/entity.py @@ -28,7 +28,7 @@ class AquacellEntity(CoordinatorEntity[AquacellCoordinator]): self._attr_unique_id = f"{softener_key}-{entity_key}" self._attr_device_info = DeviceInfo( name=self.softener.name, - hw_version=self.softener.fwVersion, + hw_version=self.softener.diagnostics.fw_version, identifiers={(DOMAIN, str(softener_key))}, manufacturer=self.softener.brand, model=self.softener.ssn, diff --git a/homeassistant/components/aquacell/manifest.json b/homeassistant/components/aquacell/manifest.json index 2d8b80f4488..41dff9b9f68 100644 --- a/homeassistant/components/aquacell/manifest.json +++ b/homeassistant/components/aquacell/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["aioaquacell"], - "requirements": ["aioaquacell==0.2.0"] + "requirements": ["aioaquacell==1.0.0"] } diff --git a/homeassistant/components/aquacell/sensor.py b/homeassistant/components/aquacell/sensor.py index 58d3548284e..892e2347e70 100644 --- a/homeassistant/components/aquacell/sensor.py +++ b/homeassistant/components/aquacell/sensor.py @@ -1,7 +1,5 @@ """Sensors exposing properties of the softener device.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -38,39 +36,39 @@ SENSORS: tuple[SoftenerSensorEntityDescription, ...] = ( translation_key="salt_left_side_percentage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda softener: softener.salt.leftPercent, + value_fn=lambda softener: softener.salt.left_percent, ), SoftenerSensorEntityDescription( key="salt_right_side_percentage", translation_key="salt_right_side_percentage", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda softener: softener.salt.rightPercent, + value_fn=lambda softener: softener.salt.right_percent, ), SoftenerSensorEntityDescription( key="salt_left_side_time_remaining", translation_key="salt_left_side_time_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.DAYS, - value_fn=lambda softener: softener.salt.leftDays, + value_fn=lambda softener: softener.salt.left_days, ), SoftenerSensorEntityDescription( key="salt_right_side_time_remaining", translation_key="salt_right_side_time_remaining", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.DAYS, - value_fn=lambda softener: softener.salt.rightDays, + value_fn=lambda softener: softener.salt.right_days, ), SoftenerSensorEntityDescription( key="battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, - value_fn=lambda softener: softener.battery, + value_fn=lambda softener: softener.diagnostics.battery, ), SoftenerSensorEntityDescription( key="wi_fi_strength", translation_key="wi_fi_strength", - value_fn=lambda softener: softener.wifiLevel, + value_fn=lambda softener: softener.diagnostics.wifi_level, device_class=SensorDeviceClass.ENUM, options=[ "high", @@ -82,7 +80,7 @@ SENSORS: tuple[SoftenerSensorEntityDescription, ...] = ( key="last_update", translation_key="last_update", device_class=SensorDeviceClass.TIMESTAMP, - value_fn=lambda softener: softener.lastUpdate, + value_fn=lambda softener: softener.diagnostics.last_update, ), ) diff --git a/homeassistant/components/aqualogic/__init__.py b/homeassistant/components/aqualogic/__init__.py index 7c3a5966d1c..cd4f02741c5 100644 --- a/homeassistant/components/aqualogic/__init__.py +++ b/homeassistant/components/aqualogic/__init__.py @@ -1,7 +1,5 @@ """Support for AquaLogic devices.""" -from __future__ import annotations - from datetime import timedelta import logging import threading diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index e0cae5df162..a76cce97197 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -1,7 +1,5 @@ """Support for AquaLogic sensors.""" -from __future__ import annotations - from dataclasses import dataclass import voluptuous as vol diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py index 667842a020c..b3ba028a33d 100644 --- a/homeassistant/components/aqualogic/switch.py +++ b/homeassistant/components/aqualogic/switch.py @@ -1,7 +1,5 @@ """Support for AquaLogic switches.""" -from __future__ import annotations - from typing import Any from aqualogic.core import States diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py index 3fc6bed54a1..3d2fc14fc7e 100644 --- a/homeassistant/components/aquostv/media_player.py +++ b/homeassistant/components/aquostv/media_player.py @@ -1,7 +1,5 @@ """Support for interface with an Aquos TV.""" -from __future__ import annotations - from collections.abc import Callable import logging from typing import Any, Concatenate diff --git a/homeassistant/components/aranet/__init__.py b/homeassistant/components/aranet/__init__.py index 81b3dae04de..cfc48648f92 100644 --- a/homeassistant/components/aranet/__init__.py +++ b/homeassistant/components/aranet/__init__.py @@ -1,7 +1,5 @@ """The Aranet integration.""" -from __future__ import annotations - import logging from aranet4.client import Aranet4Advertisement diff --git a/homeassistant/components/aranet/config_flow.py b/homeassistant/components/aranet/config_flow.py index 876b175126e..6a445d17f70 100644 --- a/homeassistant/components/aranet/config_flow.py +++ b/homeassistant/components/aranet/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Aranet integration.""" -from __future__ import annotations - from typing import Any from aranet4.client import Aranet4Advertisement, Version as AranetVersion diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index ee2eb8c8a75..0ec81ff7112 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -1,7 +1,5 @@ """Support for Aranet sensors.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/arcam_fmj/binary_sensor.py b/homeassistant/components/arcam_fmj/binary_sensor.py index 0addfdb4aa2..831fddc47ca 100644 --- a/homeassistant/components/arcam_fmj/binary_sensor.py +++ b/homeassistant/components/arcam_fmj/binary_sensor.py @@ -1,7 +1,5 @@ """Arcam binary sensors for incoming stream info.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/arcam_fmj/config_flow.py b/homeassistant/components/arcam_fmj/config_flow.py index e1886a1db60..d34b6e2f5b4 100644 --- a/homeassistant/components/arcam_fmj/config_flow.py +++ b/homeassistant/components/arcam_fmj/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Arcam FMJ component.""" -from __future__ import annotations - from typing import Any from urllib.parse import urlparse diff --git a/homeassistant/components/arcam_fmj/coordinator.py b/homeassistant/components/arcam_fmj/coordinator.py index 39b3f28fc68..7884e4b46a8 100644 --- a/homeassistant/components/arcam_fmj/coordinator.py +++ b/homeassistant/components/arcam_fmj/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Arcam FMJ integration.""" -from __future__ import annotations - from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from dataclasses import dataclass diff --git a/homeassistant/components/arcam_fmj/device_trigger.py b/homeassistant/components/arcam_fmj/device_trigger.py index 16061f3a1e1..628414c08c2 100644 --- a/homeassistant/components/arcam_fmj/device_trigger.py +++ b/homeassistant/components/arcam_fmj/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for Arcam FMJ Receiver control.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA diff --git a/homeassistant/components/arcam_fmj/entity.py b/homeassistant/components/arcam_fmj/entity.py index cf97ef32c38..4efe046df22 100644 --- a/homeassistant/components/arcam_fmj/entity.py +++ b/homeassistant/components/arcam_fmj/entity.py @@ -1,7 +1,5 @@ """Base entity for Arcam FMJ integration.""" -from __future__ import annotations - from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index ae1f86c6d35..084d26e19ae 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.8.2"], + "requirements": ["arcam-fmj==1.8.3"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py index 04451c692ce..4c63aaf1b6b 100644 --- a/homeassistant/components/arcam_fmj/media_player.py +++ b/homeassistant/components/arcam_fmj/media_player.py @@ -1,7 +1,5 @@ """Arcam media player.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine import functools import logging diff --git a/homeassistant/components/arcam_fmj/sensor.py b/homeassistant/components/arcam_fmj/sensor.py index f57ab2649dc..a2db9a5ab17 100644 --- a/homeassistant/components/arcam_fmj/sensor.py +++ b/homeassistant/components/arcam_fmj/sensor.py @@ -1,11 +1,10 @@ """Arcam sensors for incoming stream info.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass +import logging -from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace +from arcam.fmj import IncomingVideoAspectRatio, IncomingVideoColorspace, IntOrTypeEnum from arcam.fmj.state import IncomingAudioConfig, IncomingAudioFormat, State from homeassistant.components.sensor import ( @@ -21,6 +20,25 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ArcamFmjConfigEntry from .entity import ArcamFmjEntity +_LOGGER = logging.getLogger(__name__) + + +def _enum_options(value: type[IntOrTypeEnum]) -> list[str]: + return [ + member.name.lower() for member in value if not member.name.startswith("CODE_") + ] + + +def _enum_value(value: IntOrTypeEnum | None) -> str | None: + if value is None: + return None + + if value.name.startswith("CODE_"): + _LOGGER.debug("Undefined enum value %s ignored", value) + return None + + return value.name.lower() + @dataclass(frozen=True, kw_only=True) class ArcamFmjSensorEntityDescription(SensorEntityDescription): @@ -75,9 +93,9 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = ( translation_key="incoming_video_aspect_ratio", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, - options=[member.name.lower() for member in IncomingVideoAspectRatio], + options=_enum_options(IncomingVideoAspectRatio), value_fn=lambda state: ( - vp.aspect_ratio.name.lower() + _enum_value(vp.aspect_ratio) if (vp := state.get_incoming_video_parameters()) is not None else None ), @@ -87,9 +105,9 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = ( translation_key="incoming_video_colorspace", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, - options=[member.name.lower() for member in IncomingVideoColorspace], + options=_enum_options(IncomingVideoColorspace), value_fn=lambda state: ( - vp.colorspace.name.lower() + _enum_value(vp.colorspace) if (vp := state.get_incoming_video_parameters()) is not None else None ), @@ -99,24 +117,16 @@ SENSORS: tuple[ArcamFmjSensorEntityDescription, ...] = ( translation_key="incoming_audio_format", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, - options=[member.name.lower() for member in IncomingAudioFormat], - value_fn=lambda state: ( - result.name.lower() - if (result := state.get_incoming_audio_format()[0]) is not None - else None - ), + options=_enum_options(IncomingAudioFormat), + value_fn=lambda state: _enum_value(state.get_incoming_audio_format()[0]), ), ArcamFmjSensorEntityDescription( key="incoming_audio_config", translation_key="incoming_audio_config", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, - options=[member.name.lower() for member in IncomingAudioConfig], - value_fn=lambda state: ( - result.name.lower() - if (result := state.get_incoming_audio_format()[1]) is not None - else None - ), + options=_enum_options(IncomingAudioConfig), + value_fn=lambda state: _enum_value(state.get_incoming_audio_format()[1]), ), ArcamFmjSensorEntityDescription( key="incoming_audio_sample_rate", diff --git a/homeassistant/components/arest/binary_sensor.py b/homeassistant/components/arest/binary_sensor.py index a99ef049543..7e7ec8505fd 100644 --- a/homeassistant/components/arest/binary_sensor.py +++ b/homeassistant/components/arest/binary_sensor.py @@ -1,7 +1,5 @@ """Support for an exposed aREST RESTful API of a device.""" -from __future__ import annotations - from datetime import timedelta from http import HTTPStatus import logging diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 6554704b230..0eb28be205b 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -1,7 +1,5 @@ """Support for an exposed aREST RESTful API of a device.""" -from __future__ import annotations - from datetime import timedelta from http import HTTPStatus import logging diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index 7539336c38b..3ad16640e06 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -1,7 +1,5 @@ """Support for an exposed aREST RESTful API of a device.""" -from __future__ import annotations - from http import HTTPStatus import logging from typing import Any diff --git a/homeassistant/components/arris_tg2492lg/__init__.py b/homeassistant/components/arris_tg2492lg/__init__.py index c08ddcba48f..b5247e5e7d2 100644 --- a/homeassistant/components/arris_tg2492lg/__init__.py +++ b/homeassistant/components/arris_tg2492lg/__init__.py @@ -1 +1 @@ -"""The Arris TG2492LG component.""" +"""The Arris TG2492LG integration.""" diff --git a/homeassistant/components/arris_tg2492lg/device_tracker.py b/homeassistant/components/arris_tg2492lg/device_tracker.py index 828528508ec..d2ddd8b8bea 100644 --- a/homeassistant/components/arris_tg2492lg/device_tracker.py +++ b/homeassistant/components/arris_tg2492lg/device_tracker.py @@ -1,7 +1,5 @@ """Support for Arris TG2492LG router.""" -from __future__ import annotations - from aiohttp.client_exceptions import ClientResponseError from arris_tg2492lg import ConnectBox, Device import voluptuous as vol diff --git a/homeassistant/components/aruba/__init__.py b/homeassistant/components/aruba/__init__.py index cd52f7310f3..14c0b67967e 100644 --- a/homeassistant/components/aruba/__init__.py +++ b/homeassistant/components/aruba/__init__.py @@ -1 +1 @@ -"""The aruba component.""" +"""The Aruba integration.""" diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index 667f2132fc8..d85c3721973 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -1,7 +1,5 @@ """Support for Aruba Access Points.""" -from __future__ import annotations - import logging import re from typing import Any diff --git a/homeassistant/components/arve/__init__.py b/homeassistant/components/arve/__init__.py index 8a7c826f6c4..8419598c745 100644 --- a/homeassistant/components/arve/__init__.py +++ b/homeassistant/components/arve/__init__.py @@ -1,7 +1,5 @@ """The Arve integration.""" -from __future__ import annotations - import logging from homeassistant.const import Platform diff --git a/homeassistant/components/arve/config_flow.py b/homeassistant/components/arve/config_flow.py index 466ed7bad5f..adc2b5f7b9c 100644 --- a/homeassistant/components/arve/config_flow.py +++ b/homeassistant/components/arve/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Arve integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/arve/coordinator.py b/homeassistant/components/arve/coordinator.py index 4b08efd639e..2edf52bc9c7 100644 --- a/homeassistant/components/arve/coordinator.py +++ b/homeassistant/components/arve/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Arve integration.""" -from __future__ import annotations - from datetime import timedelta from asyncarve import ( diff --git a/homeassistant/components/arve/entity.py b/homeassistant/components/arve/entity.py index 46c6bfc75ec..517e6d7a231 100644 --- a/homeassistant/components/arve/entity.py +++ b/homeassistant/components/arve/entity.py @@ -1,7 +1,5 @@ """Arve base entity.""" -from __future__ import annotations - from asyncarve import ArveDeviceInfo from homeassistant.helpers.device_registry import DeviceInfo diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index bd8be66d9ba..6fb6e7a4fe6 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -1,7 +1,5 @@ """Support for collecting data from the ARWN project.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/aseko_pool_live/__init__.py b/homeassistant/components/aseko_pool_live/__init__.py index 012b5a19b0f..ea2d6f20db0 100644 --- a/homeassistant/components/aseko_pool_live/__init__.py +++ b/homeassistant/components/aseko_pool_live/__init__.py @@ -1,7 +1,5 @@ """The Aseko Pool Live integration.""" -from __future__ import annotations - import logging from aioaseko import Aseko, AsekoNotLoggedIn diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py index 15c72614ee1..7d9c18fc667 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Aseko Pool Live binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/aseko_pool_live/config_flow.py b/homeassistant/components/aseko_pool_live/config_flow.py index e93eb803d62..94bf3bceccf 100644 --- a/homeassistant/components/aseko_pool_live/config_flow.py +++ b/homeassistant/components/aseko_pool_live/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Aseko Pool Live integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/aseko_pool_live/coordinator.py b/homeassistant/components/aseko_pool_live/coordinator.py index d54aa756ddd..9cd169f37c7 100644 --- a/homeassistant/components/aseko_pool_live/coordinator.py +++ b/homeassistant/components/aseko_pool_live/coordinator.py @@ -1,7 +1,5 @@ """The Aseko Pool Live integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py index f9a7287a9f1..397a20fcac1 100644 --- a/homeassistant/components/aseko_pool_live/sensor.py +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -1,7 +1,5 @@ """Support for Aseko Pool Live sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 739d75a08ad..b1d4857659c 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -1,7 +1,5 @@ """The Assist pipeline integration.""" -from __future__ import annotations - from collections.abc import AsyncIterable from typing import Any diff --git a/homeassistant/components/assist_pipeline/error.py b/homeassistant/components/assist_pipeline/error.py index d12f41ce144..4eec9a144e7 100644 --- a/homeassistant/components/assist_pipeline/error.py +++ b/homeassistant/components/assist_pipeline/error.py @@ -1,7 +1,5 @@ """Assist pipeline errors.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/components/assist_pipeline/logbook.py b/homeassistant/components/assist_pipeline/logbook.py index b7ab24d2f2f..4a101d8dc10 100644 --- a/homeassistant/components/assist_pipeline/logbook.py +++ b/homeassistant/components/assist_pipeline/logbook.py @@ -1,7 +1,5 @@ """Describe assist_pipeline 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/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 67aa14f04d7..61bf588c973 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1,7 +1,5 @@ """Classes for voice assistant pipelines.""" -from __future__ import annotations - import array import asyncio from collections import defaultdict, deque @@ -945,7 +943,10 @@ class PipelineRun: try: # Transcribe audio stream stt_vad: VoiceCommandSegmenter | None = None - if self.audio_settings.is_vad_enabled: + if ( + self.audio_settings.is_vad_enabled + and self.stt_provider.audio_processing.requires_external_vad + ): stt_vad = VoiceCommandSegmenter( silence_seconds=self.audio_settings.silence_seconds ) diff --git a/homeassistant/components/assist_pipeline/repair_flows.py b/homeassistant/components/assist_pipeline/repair_flows.py index d3d9633bd06..ea1b3f4d83b 100644 --- a/homeassistant/components/assist_pipeline/repair_flows.py +++ b/homeassistant/components/assist_pipeline/repair_flows.py @@ -1,7 +1,5 @@ """Repairs implementation for the cloud integration.""" -from __future__ import annotations - from typing import cast import voluptuous as vol diff --git a/homeassistant/components/assist_pipeline/select.py b/homeassistant/components/assist_pipeline/select.py index dc6283ccedf..71f199163cd 100644 --- a/homeassistant/components/assist_pipeline/select.py +++ b/homeassistant/components/assist_pipeline/select.py @@ -1,7 +1,5 @@ """Select entities for a pipeline.""" -from __future__ import annotations - from collections.abc import Iterable from dataclasses import replace diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index d4647fafe2a..3ac2619c4f0 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -1,7 +1,5 @@ """Voice activity detection.""" -from __future__ import annotations - from collections.abc import Callable, Iterable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 62dcb8c1d80..dfc40551da0 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -13,11 +13,12 @@ from hassil.util import ( ) import voluptuous as vol +from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, Unauthorized, UnknownUser from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -103,6 +104,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def handle_ask_question(call: ServiceCall) -> dict[str, Any]: """Handle a Show View service call.""" satellite_entity_id: str = call.data[ATTR_ENTITY_ID] + if call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + if user is None: + raise UnknownUser( + context=call.context, + permission=POLICY_CONTROL, + user_id=call.context.user_id, + ) + if not user.permissions.check_entity(satellite_entity_id, POLICY_CONTROL): + raise Unauthorized( + context=call.context, + permission=POLICY_CONTROL, + user_id=call.context.user_id, + perm_category=CAT_ENTITIES, + ) + satellite_entity: AssistSatelliteEntity | None = component.get_entity( satellite_entity_id ) diff --git a/homeassistant/components/assist_satellite/conditions.yaml b/homeassistant/components/assist_satellite/conditions.yaml index eeb7f02b913..0af45c93f0f 100644 --- a/homeassistant/components/assist_satellite/conditions.yaml +++ b/homeassistant/components/assist_satellite/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_idle: *condition_common is_listening: *condition_common diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py index 7fca88f3b12..ef228862bd6 100644 --- a/homeassistant/components/assist_satellite/const.py +++ b/homeassistant/components/assist_satellite/const.py @@ -1,7 +1,5 @@ """Constants for assist satellite.""" -from __future__ import annotations - import asyncio from enum import IntFlag from typing import TYPE_CHECKING diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index f544ddbd8d0..ff6aef845fe 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted Assist satellites.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted Assist satellites to trigger on.", - "trigger_behavior_name": "Behavior" + "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_idle": { "description": "Tests if one or more Assist satellites are idle.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::condition_for_name%]" } }, "name": "Satellite is idle" @@ -20,8 +22,10 @@ "description": "Tests if one or more Assist satellites are listening.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::condition_for_name%]" } }, "name": "Satellite is listening" @@ -30,8 +34,10 @@ "description": "Tests if one or more Assist satellites are processing.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::condition_for_name%]" } }, "name": "Satellite is processing" @@ -40,8 +46,10 @@ "description": "Tests if one or more Assist satellites are responding.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::condition_behavior_description%]", "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::condition_for_name%]" } }, "name": "Satellite is responding" @@ -64,24 +72,11 @@ "id": "Answer ID", "sentences": "Sentences" } - }, - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } } }, "services": { "announce": { - "description": "Lets a satellite announce a message.", + "description": "Lets an Assist satellite announce a message.", "fields": { "media_id": { "description": "The media ID to announce instead of using text-to-speech.", @@ -100,10 +95,10 @@ "name": "Preannounce media ID" } }, - "name": "Announce" + "name": "Announce on satellite" }, "ask_question": { - "description": "Asks a question and gets the user's response.", + "description": "Lets an Assist satellite ask a question and get the user's response.", "fields": { "answers": { "description": "Possible answers to the question.", @@ -130,10 +125,10 @@ "name": "Question media ID" } }, - "name": "Ask question" + "name": "Ask question on satellite" }, "start_conversation": { - "description": "Starts a conversation from a satellite.", + "description": "Starts a conversation from an Assist satellite.", "fields": { "extra_system_prompt": { "description": "Provide background information to the AI about the request.", @@ -156,47 +151,55 @@ "name": "Message" } }, - "name": "Start conversation" + "name": "Start conversation on satellite" } }, "title": "Assist satellite", "triggers": { "idle": { - "description": "Triggers after one or more voice assistant satellites become idle after having processed a command.", + "description": "Triggers after one or more Assist satellites become idle after having processed a command.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::trigger_for_name%]" } }, "name": "Satellite became idle" }, "listening": { - "description": "Triggers after one or more voice assistant satellites start listening for a command from someone.", + "description": "Triggers after one or more Assist satellites start listening for a command from someone.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::trigger_for_name%]" } }, "name": "Satellite started listening" }, "processing": { - "description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.", + "description": "Triggers after one or more Assist satellites start processing a command after having heard it.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::trigger_for_name%]" } }, "name": "Satellite started processing" }, "responding": { - "description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.", + "description": "Triggers after one or more Assist satellites start responding to a command after having processed it, or start announcing something.", "fields": { "behavior": { - "description": "[%key:component::assist_satellite::common::trigger_behavior_description%]", "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::trigger_for_name%]" } }, "name": "Satellite started responding" diff --git a/homeassistant/components/assist_satellite/triggers.yaml b/homeassistant/components/assist_satellite/triggers.yaml index 0adc9e587a6..57769c93962 100644 --- a/homeassistant/components/assist_satellite/triggers.yaml +++ b/homeassistant/components/assist_satellite/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any selector: - select: - options: - - first - - last - - any - translation_key: trigger_behavior + automation_behavior: + mode: trigger + for: + required: true + default: 00:00:00 + selector: + duration: idle: *trigger_common listening: *trigger_common diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py index 6f8b3d723ad..18f512d2f21 100644 --- a/homeassistant/components/assist_satellite/websocket_api.py +++ b/homeassistant/components/assist_satellite/websocket_api.py @@ -165,6 +165,7 @@ async def websocket_set_wake_words( vol.Required("entity_id"): cv.entity_domain(DOMAIN), } ) +@websocket_api.require_admin @websocket_api.async_response async def websocket_test_connection( hass: HomeAssistant, diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index a1bfad989d4..dd50b849b75 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -1,7 +1,5 @@ """aioasuswrt and pyasuswrt bridge classes.""" -from __future__ import annotations - from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable, Coroutine import functools diff --git a/homeassistant/components/asuswrt/config_flow.py b/homeassistant/components/asuswrt/config_flow.py index fcc2a8a18d5..c1a49f05620 100644 --- a/homeassistant/components/asuswrt/config_flow.py +++ b/homeassistant/components/asuswrt/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the AsusWrt integration.""" -from __future__ import annotations - import logging import os import socket diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index 2781812cca7..51752aa3e21 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -1,7 +1,5 @@ """Support for ASUSWRT 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/asuswrt/diagnostics.py b/homeassistant/components/asuswrt/diagnostics.py index bc537d523eb..7aa6d4d8a7a 100644 --- a/homeassistant/components/asuswrt/diagnostics.py +++ b/homeassistant/components/asuswrt/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Asuswrt.""" -from __future__ import annotations - from typing import Any import attr diff --git a/homeassistant/components/asuswrt/helpers.py b/homeassistant/components/asuswrt/helpers.py index 65ebedfab4d..2779586a060 100644 --- a/homeassistant/components/asuswrt/helpers.py +++ b/homeassistant/components/asuswrt/helpers.py @@ -1,7 +1,5 @@ """Helpers for AsusWRT integration.""" -from __future__ import annotations - from typing import Any TRANSLATION_MAP = { diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index cf8995c6f63..30c2b996e9c 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -1,7 +1,5 @@ """Represent the AsusWrt router.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py index c4bd5e4bded..a4cee3a36c1 100644 --- a/homeassistant/components/asuswrt/sensor.py +++ b/homeassistant/components/asuswrt/sensor.py @@ -1,7 +1,5 @@ """Asuswrt status sensors.""" -from __future__ import annotations - from dataclasses import dataclass from homeassistant.components.sensor import ( diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py index 8f1ded150f1..0bfed3a7f3b 100644 --- a/homeassistant/components/atag/climate.py +++ b/homeassistant/components/atag/climate.py @@ -1,7 +1,5 @@ """Initialization of ATAG One climate platform.""" -from __future__ import annotations - from typing import Any from homeassistant.components.climate import ( diff --git a/homeassistant/components/aten_pe/switch.py b/homeassistant/components/aten_pe/switch.py index 30afab16011..23d9352473e 100644 --- a/homeassistant/components/aten_pe/switch.py +++ b/homeassistant/components/aten_pe/switch.py @@ -1,7 +1,5 @@ """The ATEN PE switch component.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py index a1254c1ff49..fa94297d292 100644 --- a/homeassistant/components/atome/sensor.py +++ b/homeassistant/components/atome/sensor.py @@ -1,7 +1,5 @@ """Linky Atome.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 93a540dcd18..ccd1cebea59 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -1,7 +1,5 @@ """Support for August devices.""" -from __future__ import annotations - from pathlib import Path from typing import cast diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index b4c440599c4..cd9818a077b 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -1,7 +1,5 @@ """Support for August binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 7b013022299..b2ae4865455 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -1,7 +1,5 @@ """Support for August doorbell camera.""" -from __future__ import annotations - import logging from aiohttp import ClientSession diff --git a/homeassistant/components/august/data.py b/homeassistant/components/august/data.py index 66ddfeedfde..0f2f6d75f2c 100644 --- a/homeassistant/components/august/data.py +++ b/homeassistant/components/august/data.py @@ -1,7 +1,5 @@ """Support for August 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/august/diagnostics.py b/homeassistant/components/august/diagnostics.py index b061e224df9..211a0db18db 100644 --- a/homeassistant/components/august/diagnostics.py +++ b/homeassistant/components/august/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for august.""" -from __future__ import annotations - from typing import Any from yalexs.const import DEFAULT_BRAND diff --git a/homeassistant/components/august/event.py b/homeassistant/components/august/event.py index 0abc840bc69..7c995ac713e 100644 --- a/homeassistant/components/august/event.py +++ b/homeassistant/components/august/event.py @@ -1,7 +1,5 @@ """Support for august events.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 92da05eabd1..d6954ed8851 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -1,7 +1,5 @@ """Support for August lock.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 94a5461149f..74f6e1d4723 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -1,7 +1,5 @@ """Support for August sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast diff --git a/homeassistant/components/august/util.py b/homeassistant/components/august/util.py index 5449d048613..f0b3598bb5f 100644 --- a/homeassistant/components/august/util.py +++ b/homeassistant/components/august/util.py @@ -1,7 +1,5 @@ """August util functions.""" -from __future__ import annotations - from datetime import datetime, timedelta from functools import partial diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py index 73e732dc44a..50c769dabd2 100644 --- a/homeassistant/components/aurora/binary_sensor.py +++ b/homeassistant/components/aurora/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Aurora Forecast binary sensor.""" -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/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index 521af17b659..75f99820b13 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Aurora.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/aurora/coordinator.py b/homeassistant/components/aurora/coordinator.py index a7b87baec22..829052c3a43 100644 --- a/homeassistant/components/aurora/coordinator.py +++ b/homeassistant/components/aurora/coordinator.py @@ -1,7 +1,5 @@ """The aurora component.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py index d424b7e98ab..6513dba253d 100644 --- a/homeassistant/components/aurora/sensor.py +++ b/homeassistant/components/aurora/sensor.py @@ -1,7 +1,5 @@ """Support for Aurora Forecast sensor.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/aurora_abb_powerone/config_flow.py b/homeassistant/components/aurora_abb_powerone/config_flow.py index 0b6e41257fc..1df833e81c4 100644 --- a/homeassistant/components/aurora_abb_powerone/config_flow.py +++ b/homeassistant/components/aurora_abb_powerone/config_flow.py @@ -1,15 +1,13 @@ """Config flow for Aurora ABB PowerOne integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import TYPE_CHECKING, Any from aurorapy.client import AuroraError, AuroraSerialClient -import serial.tools.list_ports import voluptuous as vol +from homeassistant.components import usb from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT from homeassistant.core import HomeAssistant @@ -57,9 +55,11 @@ def validate_and_connect( return ret -def scan_comports() -> tuple[list[str] | None, str | None]: +async def async_scan_comports( + hass: HomeAssistant, +) -> tuple[list[str] | None, str | None]: """Find and store available com ports for the GUI dropdown.""" - com_ports = serial.tools.list_ports.comports(include_links=True) + com_ports = await usb.async_scan_serial_ports(hass) com_ports_list = [] for port in com_ports: com_ports_list.append(port.device) @@ -87,7 +87,7 @@ class AuroraABBConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if self._com_ports_list is None: - result = await self.hass.async_add_executor_job(scan_comports) + result = await async_scan_comports(self.hass) self._com_ports_list, self._default_com_port = result if self._default_com_port is None: return self.async_abort(reason="no_serial_ports") diff --git a/homeassistant/components/aurora_abb_powerone/manifest.json b/homeassistant/components/aurora_abb_powerone/manifest.json index 8d33cc95d45..04728cbb47d 100644 --- a/homeassistant/components/aurora_abb_powerone/manifest.json +++ b/homeassistant/components/aurora_abb_powerone/manifest.json @@ -3,6 +3,7 @@ "name": "Aurora ABB PowerOne Solar PV", "codeowners": ["@davet2001"], "config_flow": true, + "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone", "integration_type": "device", "iot_class": "local_polling", diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index fdc9172bba6..dafcadca731 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -1,7 +1,5 @@ """Support for Aurora ABB PowerOne Solar Photovoltaic (PV) inverter.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/aussie_broadband/__init__.py b/homeassistant/components/aussie_broadband/__init__.py index 673df594e89..3dcc5ec5f4f 100644 --- a/homeassistant/components/aussie_broadband/__init__.py +++ b/homeassistant/components/aussie_broadband/__init__.py @@ -1,7 +1,5 @@ """The Aussie Broadband integration.""" -from __future__ import annotations - from aiohttp import ClientError from aussiebb.asyncio import AussieBB from aussiebb.const import FETCH_TYPES diff --git a/homeassistant/components/aussie_broadband/config_flow.py b/homeassistant/components/aussie_broadband/config_flow.py index 72ff0b3b2b2..d24d56fbf85 100644 --- a/homeassistant/components/aussie_broadband/config_flow.py +++ b/homeassistant/components/aussie_broadband/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Aussie Broadband integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/aussie_broadband/coordinator.py b/homeassistant/components/aussie_broadband/coordinator.py index 20987c5f30f..091872f3b7f 100644 --- a/homeassistant/components/aussie_broadband/coordinator.py +++ b/homeassistant/components/aussie_broadband/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Aussie Broadband integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any, TypedDict diff --git a/homeassistant/components/aussie_broadband/diagnostics.py b/homeassistant/components/aussie_broadband/diagnostics.py index 9c68c068bb0..d549fe64397 100644 --- a/homeassistant/components/aussie_broadband/diagnostics.py +++ b/homeassistant/components/aussie_broadband/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for Aussie Broadband.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/aussie_broadband/sensor.py b/homeassistant/components/aussie_broadband/sensor.py index 41a2f164095..101702d89b4 100644 --- a/homeassistant/components/aussie_broadband/sensor.py +++ b/homeassistant/components/aussie_broadband/sensor.py @@ -1,7 +1,5 @@ """Support for Aussie Broadband metric sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import re diff --git a/homeassistant/components/autarco/__init__.py b/homeassistant/components/autarco/__init__.py index a524535c122..f4de22d126e 100644 --- a/homeassistant/components/autarco/__init__.py +++ b/homeassistant/components/autarco/__init__.py @@ -1,7 +1,5 @@ """The Autarco integration.""" -from __future__ import annotations - import asyncio from autarco import Autarco, AutarcoConnectionError diff --git a/homeassistant/components/autarco/config_flow.py b/homeassistant/components/autarco/config_flow.py index 294fa685fb8..13ef0574870 100644 --- a/homeassistant/components/autarco/config_flow.py +++ b/homeassistant/components/autarco/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Autarco integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/autarco/const.py b/homeassistant/components/autarco/const.py index 45a2825e793..ea55125725f 100644 --- a/homeassistant/components/autarco/const.py +++ b/homeassistant/components/autarco/const.py @@ -1,7 +1,5 @@ """Constants for the Autarco integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/autarco/coordinator.py b/homeassistant/components/autarco/coordinator.py index deb40155443..0b1733fa7d3 100644 --- a/homeassistant/components/autarco/coordinator.py +++ b/homeassistant/components/autarco/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Autarco integration.""" -from __future__ import annotations - from typing import NamedTuple from autarco import ( diff --git a/homeassistant/components/autarco/diagnostics.py b/homeassistant/components/autarco/diagnostics.py index a2dd0c5a361..eed42c150c2 100644 --- a/homeassistant/components/autarco/diagnostics.py +++ b/homeassistant/components/autarco/diagnostics.py @@ -1,7 +1,5 @@ """Support for the Autarco diagnostics.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/autarco/sensor.py b/homeassistant/components/autarco/sensor.py index 292dfa93644..82edebc2279 100644 --- a/homeassistant/components/autarco/sensor.py +++ b/homeassistant/components/autarco/sensor.py @@ -1,7 +1,5 @@ """Support for Autarco sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 33aeb283f5a..6e0bc549fd6 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -123,8 +123,6 @@ that link accounts with other cloud providers using LocalOAuth2Implementation as part of a config flow. """ -from __future__ import annotations - import asyncio from collections.abc import Callable from datetime import datetime, timedelta @@ -157,7 +155,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import OAuth2AuthorizeCallbackView from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey @@ -173,7 +170,6 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) DELETE_CURRENT_TOKEN_DELAY = 2 -@bind_hass def create_auth_code( hass: HomeAssistant, client_id: str, credential: Credentials ) -> str: diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index dec6767d807..af98deb7c15 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -1,7 +1,5 @@ """Helpers to resolve client ID/secret.""" -from __future__ import annotations - from html.parser import HTMLParser from ipaddress import ip_address import logging diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index 12d108f7942..955269908b4 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -67,8 +67,6 @@ associate with an credential if "type" set to "link_user" in } """ -from __future__ import annotations - from collections.abc import Callable from http import HTTPStatus from ipaddress import ip_address diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index 5b4a539b86f..85c70ceadd7 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -1,7 +1,5 @@ """Helpers to setup multi-factor auth module.""" -from __future__ import annotations - import logging from typing import Any @@ -15,24 +13,6 @@ from homeassistant.data_entry_flow import FlowContext from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey -WS_TYPE_SETUP_MFA = "auth/setup_mfa" -SCHEMA_WS_SETUP_MFA = vol.All( - websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - { - vol.Required("type"): WS_TYPE_SETUP_MFA, - vol.Exclusive("mfa_module_id", "module_or_flow_id"): str, - vol.Exclusive("flow_id", "module_or_flow_id"): str, - vol.Optional("user_input"): object, - } - ), - cv.has_at_least_one_key("mfa_module_id", "flow_id"), -) - -WS_TYPE_DEPOSE_MFA = "auth/depose_mfa" -SCHEMA_WS_DEPOSE_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_DEPOSE_MFA, vol.Required("mfa_module_id"): str} -) - DATA_SETUP_FLOW_MGR: HassKey[MfaFlowManager] = HassKey("auth_mfa_setup_flow_manager") _LOGGER = logging.getLogger(__name__) @@ -73,16 +53,24 @@ def async_setup(hass: HomeAssistant) -> None: """Init mfa setup flow manager.""" hass.data[DATA_SETUP_FLOW_MGR] = MfaFlowManager(hass) - websocket_api.async_register_command( - hass, WS_TYPE_SETUP_MFA, websocket_setup_mfa, SCHEMA_WS_SETUP_MFA - ) - - websocket_api.async_register_command( - hass, WS_TYPE_DEPOSE_MFA, websocket_depose_mfa, SCHEMA_WS_DEPOSE_MFA - ) + websocket_api.async_register_command(hass, websocket_setup_mfa) + websocket_api.async_register_command(hass, websocket_depose_mfa) @callback +@websocket_api.websocket_command( + vol.All( + vol.Schema( + { + vol.Required("type"): "auth/setup_mfa", + vol.Exclusive("mfa_module_id", "module_or_flow_id"): str, + vol.Exclusive("flow_id", "module_or_flow_id"): str, + vol.Optional("user_input"): object, + } + ), + cv.has_at_least_one_key("mfa_module_id", "flow_id"), + ) +) @websocket_api.ws_require_user(allow_system_user=False) def websocket_setup_mfa( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] @@ -121,6 +109,9 @@ def websocket_setup_mfa( @callback +@websocket_api.websocket_command( + {vol.Required("type"): "auth/depose_mfa", vol.Required("mfa_module_id"): str} +) @websocket_api.ws_require_user(allow_system_user=False) def websocket_depose_mfa( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 8887674dcdb..13df2e67d2c 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -1,13 +1,11 @@ """Allow to set up simple automation rules via the config file.""" -from __future__ import annotations - from abc import ABC, abstractmethod import asyncio -from collections.abc import Callable, Mapping +from collections.abc import Callable from dataclasses import dataclass import logging -from typing import Any, Protocol, cast +from typing import Any, cast from propcache.api import cached_property import voluptuous as vol @@ -83,7 +81,6 @@ from homeassistant.helpers.trace import ( trace_path, ) from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from homeassistant.util.dt import parse_datetime from homeassistant.util.hass_dict import HassKey @@ -143,6 +140,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { "occupancy", "person", "power", + "remote", "schedule", "select", "siren", @@ -150,6 +148,8 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = { "temperature", "text", "timer", + "todo", + "update", "vacuum", "valve", "water_heater", @@ -167,6 +167,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "cover", "device_tracker", "door", + "doorbell", "event", "fan", "garage_door", @@ -191,6 +192,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = { "switch", "temperature", "text", + "timer", "todo", "update", "vacuum", @@ -226,16 +228,12 @@ def is_disabled_experimental_trigger(hass: HomeAssistant, platform: str) -> bool ) -class IfAction(Protocol): +class IfAction(condition_helper.ConditionsChecker): """Define the format of if_action.""" config: list[ConfigType] - def __call__(self, variables: Mapping[str, Any] | None = None) -> bool: - """AND all conditions.""" - -@bind_hass def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return true if specified automation entity_id is on. @@ -833,7 +831,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): if ( not skip_condition and self._condition is not None - and not self._condition(variables) + and not self._condition.async_check(variables=variables) ): self._logger.debug( "Conditions not met, aborting automation. Condition summary: %s", @@ -901,7 +899,15 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): async def async_will_remove_from_hass(self) -> None: """Remove listeners when removing automation from Home Assistant.""" await super().async_will_remove_from_hass() - await self._async_disable() + if self.registry_entry and self.registry_entry.entity_id != self.entity_id: + # Entity ID change, do not unload the script or conditions as they will + # be reused. + await self._async_disable() + return + await self._async_disable(stop_actions=False) + await self.action_script.async_unload() + if self._condition is not None: + self._condition.async_unload() async def _async_enable_automation(self, event: Event) -> None: """Start automation on startup.""" @@ -1274,6 +1280,7 @@ async def _async_process_if( @websocket_api.websocket_command({"type": "automation/config", "entity_id": str}) +@websocket_api.require_admin def websocket_config( hass: HomeAssistant, connection: websocket_api.ActiveConnection, diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index ae25103b426..e67374cd123 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -1,7 +1,5 @@ """Config validation helper for the automation integration.""" -from __future__ import annotations - from collections.abc import Mapping from contextlib import suppress from enum import StrEnum diff --git a/homeassistant/components/automation/reproduce_state.py b/homeassistant/components/automation/reproduce_state.py index 06c982f5670..13b1193f9e5 100644 --- a/homeassistant/components/automation/reproduce_state.py +++ b/homeassistant/components/automation/reproduce_state.py @@ -1,7 +1,5 @@ """Reproduce an Automation state.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging diff --git a/homeassistant/components/automation/trace.py b/homeassistant/components/automation/trace.py index ed30b0d348b..6ccf2b97a91 100644 --- a/homeassistant/components/automation/trace.py +++ b/homeassistant/components/automation/trace.py @@ -1,7 +1,5 @@ """Trace support for automation.""" -from __future__ import annotations - from collections.abc import Generator from contextlib import contextmanager from typing import Any diff --git a/homeassistant/components/autoskope/__init__.py b/homeassistant/components/autoskope/__init__.py index a269976dc35..52f0484142a 100644 --- a/homeassistant/components/autoskope/__init__.py +++ b/homeassistant/components/autoskope/__init__.py @@ -1,14 +1,12 @@ """The Autoskope integration.""" -from __future__ import annotations - import aiohttp from autoskope_client.api import AutoskopeApi from autoskope_client.models import CannotConnect, InvalidAuth from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import DEFAULT_HOST @@ -31,8 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) -> try: await api.connect() except InvalidAuth as err: - # Raise ConfigEntryError until reauth flow is implemented (then ConfigEntryAuthFailed) - raise ConfigEntryError( + raise ConfigEntryAuthFailed( "Authentication failed, please check credentials" ) from err except CannotConnect as err: diff --git a/homeassistant/components/autoskope/config_flow.py b/homeassistant/components/autoskope/config_flow.py index 3f141b4663f..0df368da5a5 100644 --- a/homeassistant/components/autoskope/config_flow.py +++ b/homeassistant/components/autoskope/config_flow.py @@ -1,7 +1,6 @@ """Config flow for the Autoskope integration.""" -from __future__ import annotations - +from collections.abc import Mapping from typing import Any from autoskope_client.api import AutoskopeApi @@ -39,12 +38,39 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Autoskope.""" VERSION = 1 + async def _async_validate_credentials( + self, host: str, username: str, password: str, errors: dict[str, str] + ) -> bool: + """Validate credentials against the Autoskope API.""" + try: + async with AutoskopeApi( + host=host, + username=username, + password=password, + ): + pass + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + else: + return True + return False + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -63,18 +89,9 @@ class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(f"{username}@{host}") self._abort_if_unique_id_configured() - try: - async with AutoskopeApi( - host=host, - username=username, - password=user_input[CONF_PASSWORD], - ): - pass - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - else: + if await self._async_validate_credentials( + host, username, user_input[CONF_PASSWORD], errors + ): return self.async_create_entry( title=f"Autoskope ({username})", data={ @@ -87,3 +104,35 @@ class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle initiation of re-authentication.""" + 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 with new credentials.""" + errors: dict[str, str] = {} + + if user_input is not None: + reauth_entry = self._get_reauth_entry() + + if await self._async_validate_credentials( + reauth_entry.data[CONF_HOST], + reauth_entry.data[CONF_USERNAME], + user_input[CONF_PASSWORD], + errors, + ): + return self.async_update_reload_and_abort( + reauth_entry, + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/autoskope/coordinator.py b/homeassistant/components/autoskope/coordinator.py index 2c4e159396b..b7b5a3f18c2 100644 --- a/homeassistant/components/autoskope/coordinator.py +++ b/homeassistant/components/autoskope/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for the Autoskope integration.""" -from __future__ import annotations - import logging from autoskope_client.api import AutoskopeApi diff --git a/homeassistant/components/autoskope/device_tracker.py b/homeassistant/components/autoskope/device_tracker.py index 228edfd444f..a2f507c06db 100644 --- a/homeassistant/components/autoskope/device_tracker.py +++ b/homeassistant/components/autoskope/device_tracker.py @@ -1,7 +1,5 @@ """Support for Autoskope device tracking.""" -from __future__ import annotations - from autoskope_client.constants import MANUFACTURER from autoskope_client.models import Vehicle diff --git a/homeassistant/components/autoskope/quality_scale.yaml b/homeassistant/components/autoskope/quality_scale.yaml index c0af808b099..264d9c35e7a 100644 --- a/homeassistant/components/autoskope/quality_scale.yaml +++ b/homeassistant/components/autoskope/quality_scale.yaml @@ -39,10 +39,7 @@ rules: integration-owner: done log-when-unavailable: todo parallel-updates: done - reauthentication-flow: - status: todo - comment: | - Reauthentication flow removed for initial PR, will be added in follow-up. + reauthentication-flow: done test-coverage: done # Gold devices: done diff --git a/homeassistant/components/autoskope/strings.json b/homeassistant/components/autoskope/strings.json index d3a05f9f286..18e83c0866c 100644 --- a/homeassistant/components/autoskope/strings.json +++ b/homeassistant/components/autoskope/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -10,6 +11,15 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "The new password for your Autoskope account." + }, + "description": "Please re-enter your password for your Autoskope account." + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/homeassistant/components/avea/light.py b/homeassistant/components/avea/light.py index ec39a6f371c..1de386b88c8 100644 --- a/homeassistant/components/avea/light.py +++ b/homeassistant/components/avea/light.py @@ -1,7 +1,5 @@ """Support for the Elgato Avea lights.""" -from __future__ import annotations - from typing import Any import avea diff --git a/homeassistant/components/avion/light.py b/homeassistant/components/avion/light.py index 5b9371e0e2b..0db03839aaf 100644 --- a/homeassistant/components/avion/light.py +++ b/homeassistant/components/avion/light.py @@ -1,7 +1,5 @@ """Support for Avion dimmers.""" -from __future__ import annotations - import importlib import time from typing import Any diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index e3e5f1f97fc..9fd668bcdd6 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -1,7 +1,5 @@ """The awair component.""" -from __future__ import annotations - from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession diff --git a/homeassistant/components/awair/config_flow.py b/homeassistant/components/awair/config_flow.py index 429187e1f5b..9b1d363fc09 100644 --- a/homeassistant/components/awair/config_flow.py +++ b/homeassistant/components/awair/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Awair.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, Self, cast diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index a7bb8a0c550..7558768d89a 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -1,7 +1,5 @@ """Constants for the Awair component.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/awair/coordinator.py b/homeassistant/components/awair/coordinator.py index 62725693522..4f4eea2729b 100644 --- a/homeassistant/components/awair/coordinator.py +++ b/homeassistant/components/awair/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinators for awair integration.""" -from __future__ import annotations - from asyncio import gather, timeout from dataclasses import dataclass from datetime import timedelta diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index b0a44cb3e17..7b8ddf590fd 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -1,7 +1,5 @@ """Support for Awair sensors.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any, cast diff --git a/homeassistant/components/aws/__init__.py b/homeassistant/components/aws/__init__.py index da84c8985f5..c7bf3e1a9ea 100644 --- a/homeassistant/components/aws/__init__.py +++ b/homeassistant/components/aws/__init__.py @@ -2,7 +2,9 @@ import asyncio from collections import OrderedDict +from dataclasses import dataclass import logging +from typing import Any from aiobotocore.session import AioSession import voluptuous as vol @@ -30,14 +32,22 @@ from .const import ( CONF_REGION, CONF_SECRET_ACCESS_KEY, CONF_VALIDATE, - DATA_CONFIG, - DATA_HASS_CONFIG, - DATA_SESSIONS, + DATA_AWS, DOMAIN, ) _LOGGER = logging.getLogger(__name__) + +@dataclass +class AWSData: + """Runtime data for the AWS integration.""" + + hass_config: ConfigType + config: dict[str, Any] + sessions: OrderedDict[str, AioSession] + + AWS_CREDENTIAL_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.string, @@ -88,14 +98,13 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up AWS component.""" - hass.data[DATA_HASS_CONFIG] = config - if (conf := config.get(DOMAIN)) is None: # create a default conf using default profile conf = CONFIG_SCHEMA({ATTR_CREDENTIALS: DEFAULT_CREDENTIAL}) - hass.data[DATA_CONFIG] = conf - hass.data[DATA_SESSIONS] = OrderedDict() + hass.data[DATA_AWS] = AWSData( + hass_config=config, config=conf, sessions=OrderedDict() + ) hass.async_create_task( hass.config_entries.flow.async_init( @@ -111,8 +120,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: Validate and save sessions per aws credential. """ - config = hass.data[DATA_HASS_CONFIG] - conf = hass.data[DATA_CONFIG] + data = hass.data[DATA_AWS] + conf = data.config if entry.source == config_entries.SOURCE_IMPORT: if conf is None: @@ -143,14 +152,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) validation = False else: - hass.data[DATA_SESSIONS][name] = result + data.sessions[name] = result # set up notify platform, no entry support for notify component yet, # have to use discovery to load platform. for notify_config in conf[CONF_NOTIFY]: hass.async_create_task( discovery.async_load_platform( - hass, Platform.NOTIFY, DOMAIN, notify_config, config + hass, Platform.NOTIFY, DOMAIN, notify_config, data.hass_config ) ) diff --git a/homeassistant/components/aws/const.py b/homeassistant/components/aws/const.py index c885495934f..bb1055cd4ca 100644 --- a/homeassistant/components/aws/const.py +++ b/homeassistant/components/aws/const.py @@ -1,10 +1,15 @@ """Constant for AWS component.""" +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from . import AWSData + DOMAIN = "aws" -DATA_CONFIG = "aws_config" -DATA_HASS_CONFIG = "aws_hass_config" -DATA_SESSIONS = "aws_sessions" +DATA_AWS: HassKey[AWSData] = HassKey(DOMAIN) CONF_ACCESS_KEY_ID = "aws_access_key_id" CONF_CONTEXT = "context" diff --git a/homeassistant/components/aws/notify.py b/homeassistant/components/aws/notify.py index 47d66900eb0..d2dd44eedfa 100644 --- a/homeassistant/components/aws/notify.py +++ b/homeassistant/components/aws/notify.py @@ -1,7 +1,5 @@ """AWS platform for notify component.""" -from __future__ import annotations - import asyncio import base64 import json @@ -27,7 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_CONTEXT, CONF_CREDENTIAL_NAME, CONF_REGION, DATA_SESSIONS +from .const import CONF_CONTEXT, CONF_CREDENTIAL_NAME, CONF_REGION, DATA_AWS _LOGGER = logging.getLogger(__name__) @@ -76,10 +74,12 @@ async def async_get_service( if CONF_CONTEXT in aws_config: del aws_config[CONF_CONTEXT] + sessions = hass.data[DATA_AWS].sessions + if not aws_config: # no platform config, use the first aws component credential instead - if hass.data[DATA_SESSIONS]: - session = next(iter(hass.data[DATA_SESSIONS].values())) + if sessions: + session = next(iter(sessions.values())) else: _LOGGER.error("Missing aws credential for %s", config[CONF_NAME]) return None @@ -87,7 +87,7 @@ async def async_get_service( if session is None: credential_name = aws_config.get(CONF_CREDENTIAL_NAME) if credential_name is not None: - session = hass.data[DATA_SESSIONS].get(credential_name) + session = sessions.get(credential_name) if session is None: _LOGGER.warning("No available aws session for %s", credential_name) del aws_config[CONF_CREDENTIAL_NAME] diff --git a/homeassistant/components/aws_s3/__init__.py b/homeassistant/components/aws_s3/__init__.py index 57f2a45f183..e28f22634f8 100644 --- a/homeassistant/components/aws_s3/__init__.py +++ b/homeassistant/components/aws_s3/__init__.py @@ -1,7 +1,5 @@ """The AWS S3 integration.""" -from __future__ import annotations - import logging from typing import cast diff --git a/homeassistant/components/aws_s3/config_flow.py b/homeassistant/components/aws_s3/config_flow.py index cb9d363172a..1f2091c7af8 100644 --- a/homeassistant/components/aws_s3/config_flow.py +++ b/homeassistant/components/aws_s3/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the AWS S3 integration.""" -from __future__ import annotations - from typing import Any from urllib.parse import urlparse diff --git a/homeassistant/components/aws_s3/coordinator.py b/homeassistant/components/aws_s3/coordinator.py index 08df1dd4520..e92fb7add1d 100644 --- a/homeassistant/components/aws_s3/coordinator.py +++ b/homeassistant/components/aws_s3/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for AWS S3.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/aws_s3/diagnostics.py b/homeassistant/components/aws_s3/diagnostics.py index 85acf83816a..aa3b0b2a395 100644 --- a/homeassistant/components/aws_s3/diagnostics.py +++ b/homeassistant/components/aws_s3/diagnostics.py @@ -1,14 +1,9 @@ """Diagnostics support for AWS S3.""" -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.core import HomeAssistant @@ -31,7 +26,7 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """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 async_list_backups_from_s3( coordinator.client, bucket=entry.data[CONF_BUCKET], diff --git a/homeassistant/components/aws_s3/helpers.py b/homeassistant/components/aws_s3/helpers.py index 4a5af12a4c0..37c1c892873 100644 --- a/homeassistant/components/aws_s3/helpers.py +++ b/homeassistant/components/aws_s3/helpers.py @@ -1,7 +1,5 @@ """Helpers for the AWS S3 integration.""" -from __future__ import annotations - import json import logging from typing import Any diff --git a/homeassistant/components/aws_s3/sensor.py b/homeassistant/components/aws_s3/sensor.py index 95e742cb2d9..ac924eab036 100644 --- a/homeassistant/components/aws_s3/sensor.py +++ b/homeassistant/components/aws_s3/sensor.py @@ -1,7 +1,5 @@ """Support for AWS S3 sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index 6933380c094..e222f0cefa0 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Axis binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 7ac0a3a0ebd..c05ad6cf601 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure Axis devices.""" -from __future__ import annotations - from collections.abc import Mapping from ipaddress import ip_address from typing import Any diff --git a/homeassistant/components/axis/const.py b/homeassistant/components/axis/const.py index d315214c0e7..2b7e7c5644a 100644 --- a/homeassistant/components/axis/const.py +++ b/homeassistant/components/axis/const.py @@ -18,4 +18,10 @@ DEFAULT_STREAM_PROFILE = "No stream profile" DEFAULT_TRIGGER_TIME = 0 DEFAULT_VIDEO_SOURCE = "No video source" -PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.LIGHT, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.EVENT, + Platform.LIGHT, + Platform.SWITCH, +] diff --git a/homeassistant/components/axis/diagnostics.py b/homeassistant/components/axis/diagnostics.py index ffc2b36db82..21ec290db49 100644 --- a/homeassistant/components/axis/diagnostics.py +++ b/homeassistant/components/axis/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Axis.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/axis/entity.py b/homeassistant/components/axis/entity.py index 596d07de40f..ce984227112 100644 --- a/homeassistant/components/axis/entity.py +++ b/homeassistant/components/axis/entity.py @@ -1,7 +1,5 @@ """Base classes for Axis entities.""" -from __future__ import annotations - from abc import abstractmethod from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/axis/event.py b/homeassistant/components/axis/event.py new file mode 100644 index 00000000000..9cd06223b9f --- /dev/null +++ b/homeassistant/components/axis/event.py @@ -0,0 +1,60 @@ +"""Support for Axis event entities.""" + +from dataclasses import dataclass + +from axis.models.event import Event, EventTopic + +from homeassistant.components.event import ( + DoorbellEventType, + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import AxisConfigEntry +from .entity import AxisEventDescription, AxisEventEntity + +DOORBELL_CONFIG = ("I8116-E", "0") + + +@dataclass(frozen=True, kw_only=True) +class AxisEventPlatformDescription(AxisEventDescription, EventEntityDescription): + """Axis event entity description.""" + + +ENTITY_DESCRIPTIONS = ( + AxisEventPlatformDescription( + key="Doorbell", + device_class=EventDeviceClass.DOORBELL, + event_types=[DoorbellEventType.RING], + event_topic=EventTopic.PORT_INPUT, + name_fn=lambda _hub, _event: "Doorbell", + supported_fn=lambda hub, event: (hub.config.model, event.id) == DOORBELL_CONFIG, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AxisConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up an Axis event platform.""" + config_entry.runtime_data.entity_loader.register_platform( + async_add_entities, AxisEvent, ENTITY_DESCRIPTIONS + ) + + +class AxisEvent(AxisEventEntity, EventEntity): + """Representation of an Axis event entity.""" + + entity_description: AxisEventPlatformDescription + + @callback + def async_event_callback(self, event: Event) -> None: + """Handle Axis event updates.""" + if event.is_tripped: + self._trigger_event(DoorbellEventType.RING) + self.async_write_ha_state() diff --git a/homeassistant/components/axis/hub/api.py b/homeassistant/components/axis/hub/api.py index 2bfce19bae5..0229a015769 100644 --- a/homeassistant/components/axis/hub/api.py +++ b/homeassistant/components/axis/hub/api.py @@ -36,6 +36,7 @@ async def get_axis_api( username=config[CONF_USERNAME], password=config[CONF_PASSWORD], web_proto=config.get(CONF_PROTOCOL, "http"), + websocket_enabled=True, ) ) diff --git a/homeassistant/components/axis/hub/config.py b/homeassistant/components/axis/hub/config.py index eba706edc83..9002806e24a 100644 --- a/homeassistant/components/axis/hub/config.py +++ b/homeassistant/components/axis/hub/config.py @@ -1,7 +1,5 @@ """Axis network device abstraction.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Self diff --git a/homeassistant/components/axis/hub/entity_loader.py b/homeassistant/components/axis/hub/entity_loader.py index 54815ff9a69..e15cccf823f 100644 --- a/homeassistant/components/axis/hub/entity_loader.py +++ b/homeassistant/components/axis/hub/entity_loader.py @@ -3,8 +3,6 @@ Central point to load entities for the different platforms. """ -from __future__ import annotations - from typing import TYPE_CHECKING from axis.models.event import Event, EventOperation, EventTopic diff --git a/homeassistant/components/axis/hub/event_source.py b/homeassistant/components/axis/hub/event_source.py index d295639d1a6..efa9ec39eeb 100644 --- a/homeassistant/components/axis/hub/event_source.py +++ b/homeassistant/components/axis/hub/event_source.py @@ -1,7 +1,5 @@ """Axis network device abstraction.""" -from __future__ import annotations - import axis from axis.errors import Unauthorized from axis.interfaces.mqtt import mqtt_json_to_event diff --git a/homeassistant/components/axis/hub/hub.py b/homeassistant/components/axis/hub/hub.py index 6caa8fd6871..3262bdba6c9 100644 --- a/homeassistant/components/axis/hub/hub.py +++ b/homeassistant/components/axis/hub/hub.py @@ -1,7 +1,5 @@ """Axis network device abstraction.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any import axis diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index 072d0378ec0..ca90c2d0e03 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -29,7 +29,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["axis"], - "requirements": ["axis==67"], + "requirements": ["axis==69"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/azure_data_explorer/__init__.py b/homeassistant/components/azure_data_explorer/__init__.py index c416fc1cba9..9f6e03f88d2 100644 --- a/homeassistant/components/azure_data_explorer/__init__.py +++ b/homeassistant/components/azure_data_explorer/__init__.py @@ -1,7 +1,5 @@ """The Azure Data Explorer integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/azure_data_explorer/client.py b/homeassistant/components/azure_data_explorer/client.py index d8b34a50102..680235599f5 100644 --- a/homeassistant/components/azure_data_explorer/client.py +++ b/homeassistant/components/azure_data_explorer/client.py @@ -1,7 +1,5 @@ """Setting up the Azure Data Explorer ingest client.""" -from __future__ import annotations - from collections.abc import Mapping import io import logging diff --git a/homeassistant/components/azure_data_explorer/config_flow.py b/homeassistant/components/azure_data_explorer/config_flow.py index 3db734b08e6..d97197167ff 100644 --- a/homeassistant/components/azure_data_explorer/config_flow.py +++ b/homeassistant/components/azure_data_explorer/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Azure Data Explorer integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/azure_data_explorer/const.py b/homeassistant/components/azure_data_explorer/const.py index d6ab0bb499c..a47ebf54603 100644 --- a/homeassistant/components/azure_data_explorer/const.py +++ b/homeassistant/components/azure_data_explorer/const.py @@ -1,7 +1,5 @@ """Constants for the Azure Data Explorer integration.""" -from __future__ import annotations - from typing import Any from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN diff --git a/homeassistant/components/azure_devops/__init__.py b/homeassistant/components/azure_devops/__init__.py index 0522e9778df..03ee66f4d25 100644 --- a/homeassistant/components/azure_devops/__init__.py +++ b/homeassistant/components/azure_devops/__init__.py @@ -1,7 +1,5 @@ """Support for Azure DevOps.""" -from __future__ import annotations - import logging from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/azure_devops/config_flow.py b/homeassistant/components/azure_devops/config_flow.py index 13666343e1d..d8716c58380 100644 --- a/homeassistant/components/azure_devops/config_flow.py +++ b/homeassistant/components/azure_devops/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Azure DevOps integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py index 55c821a119e..469e14179e4 100644 --- a/homeassistant/components/azure_devops/sensor.py +++ b/homeassistant/components/azure_devops/sensor.py @@ -1,7 +1,5 @@ """Support for Azure DevOps sensors.""" -from __future__ import annotations - from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index 6a035e664d4..0b3120bf498 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -1,7 +1,5 @@ """Support for Azure Event Hubs.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Mapping from datetime import datetime diff --git a/homeassistant/components/azure_event_hub/client.py b/homeassistant/components/azure_event_hub/client.py index 0bf2cb69583..a9d3af506b3 100644 --- a/homeassistant/components/azure_event_hub/client.py +++ b/homeassistant/components/azure_event_hub/client.py @@ -1,7 +1,5 @@ """File for Azure Event Hub models.""" -from __future__ import annotations - from abc import ABC, abstractmethod from dataclasses import dataclass import logging diff --git a/homeassistant/components/azure_event_hub/config_flow.py b/homeassistant/components/azure_event_hub/config_flow.py index baed866042e..f1b571cbffa 100644 --- a/homeassistant/components/azure_event_hub/config_flow.py +++ b/homeassistant/components/azure_event_hub/config_flow.py @@ -1,7 +1,5 @@ """Config flow for azure_event_hub integration.""" -from __future__ import annotations - from copy import deepcopy import logging from typing import Any diff --git a/homeassistant/components/azure_event_hub/const.py b/homeassistant/components/azure_event_hub/const.py index 59a287ac6ca..6f11e6e03b3 100644 --- a/homeassistant/components/azure_event_hub/const.py +++ b/homeassistant/components/azure_event_hub/const.py @@ -1,7 +1,5 @@ """Constants and shared schema for the Azure Event Hub integration.""" -from __future__ import annotations - from typing import Any from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN diff --git a/homeassistant/components/azure_service_bus/notify.py b/homeassistant/components/azure_service_bus/notify.py index 5943ec8f855..054fa8eeef1 100644 --- a/homeassistant/components/azure_service_bus/notify.py +++ b/homeassistant/components/azure_service_bus/notify.py @@ -1,7 +1,5 @@ """Support for azure service bus notification.""" -from __future__ import annotations - import json import logging from typing import Any diff --git a/homeassistant/components/azure_storage/backup.py b/homeassistant/components/azure_storage/backup.py index 5a684bfcc77..8816d896df5 100644 --- a/homeassistant/components/azure_storage/backup.py +++ b/homeassistant/components/azure_storage/backup.py @@ -1,7 +1,5 @@ """Support for Azure Storage backup.""" -from __future__ import annotations - from collections.abc import AsyncIterator, Callable, Coroutine from functools import wraps import json diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json index 13d16dd596e..68853ecd605 100644 --- a/homeassistant/components/azure_storage/strings.json +++ b/homeassistant/components/azure_storage/strings.json @@ -54,7 +54,7 @@ "message": "Storage account {account_name} not found" }, "cannot_connect": { - "message": "Can not connect to storage account {account_name}" + "message": "Cannot connect to storage account {account_name}" }, "container_not_found": { "message": "Storage container {container_name} not found" diff --git a/homeassistant/components/backblaze_b2/__init__.py b/homeassistant/components/backblaze_b2/__init__.py index 3a8d53f5b2a..12c5f43e647 100644 --- a/homeassistant/components/backblaze_b2/__init__.py +++ b/homeassistant/components/backblaze_b2/__init__.py @@ -1,7 +1,5 @@ """The Backblaze B2 integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any @@ -74,6 +72,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackblazeConfigEntry) -> translation_domain=DOMAIN, translation_key="invalid_bucket_name", ) from err + except exception.BadRequest as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="bad_request", + translation_placeholders={"error_message": str(err)}, + ) from err except ( exception.B2ConnectionError, exception.B2RequestTimeout, diff --git a/homeassistant/components/backblaze_b2/backup.py b/homeassistant/components/backblaze_b2/backup.py index ec92a41a5dc..5098c96b0ee 100644 --- a/homeassistant/components/backblaze_b2/backup.py +++ b/homeassistant/components/backblaze_b2/backup.py @@ -101,8 +101,7 @@ def handle_b2_errors[T]( try: return await func(*args, **kwargs) except B2Error as err: - error_msg = f"Failed during {func.__name__}" - raise BackupAgentError(error_msg) from err + raise BackupAgentError(f"Failed during {func.__name__}: {err}") from err return wrapper @@ -170,8 +169,7 @@ class BackblazeBackupAgent(BackupAgent): async def _cleanup_failed_upload(self, filename: str) -> None: """Clean up a partially uploaded file after upload failure.""" _LOGGER.warning( - "Attempting to delete partially uploaded main backup file %s " - "due to metadata upload failure", + "Attempting to delete partially uploaded backup file %s", filename, ) try: @@ -180,11 +178,10 @@ class BackblazeBackupAgent(BackupAgent): ) await self._hass.async_add_executor_job(uploaded_main_file_info.delete) except B2Error: - _LOGGER.debug( - "Failed to clean up partially uploaded main backup file %s. " - "Manual intervention may be required to delete it from Backblaze B2", + _LOGGER.warning( + "Failed to clean up partially uploaded backup file %s;" + " manual deletion from Backblaze B2 may be required", filename, - exc_info=True, ) else: _LOGGER.debug( @@ -256,9 +253,10 @@ class BackblazeBackupAgent(BackupAgent): prefixed_metadata_filename, ) - upload_successful = False + tar_uploaded = False try: await self._upload_backup_file(prefixed_tar_filename, open_stream, {}) + tar_uploaded = True _LOGGER.debug( "Main backup file upload finished for %s", prefixed_tar_filename ) @@ -270,15 +268,14 @@ class BackblazeBackupAgent(BackupAgent): _LOGGER.debug( "Metadata file upload finished for %s", prefixed_metadata_filename ) - upload_successful = True - finally: - if upload_successful: - _LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename) - self._invalidate_caches( - backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename - ) - else: + _LOGGER.debug("Backup upload complete: %s", prefixed_tar_filename) + self._invalidate_caches( + backup.backup_id, prefixed_tar_filename, prefixed_metadata_filename + ) + except B2Error: + if tar_uploaded: await self._cleanup_failed_upload(prefixed_tar_filename) + raise def _upload_metadata_file_sync( self, metadata_content: bytes, filename: str diff --git a/homeassistant/components/backblaze_b2/config_flow.py b/homeassistant/components/backblaze_b2/config_flow.py index 45acf01c874..8114d920b97 100644 --- a/homeassistant/components/backblaze_b2/config_flow.py +++ b/homeassistant/components/backblaze_b2/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Backblaze B2 integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any @@ -174,6 +172,14 @@ class BackblazeConfigFlow(ConfigFlow, domain=DOMAIN): "Backblaze B2 bucket '%s' does not exist", user_input[CONF_BUCKET] ) errors[CONF_BUCKET] = "invalid_bucket_name" + except exception.BadRequest as err: + _LOGGER.error( + "Backblaze B2 API rejected the request for Key ID '%s': %s", + user_input[CONF_KEY_ID], + err, + ) + errors["base"] = "bad_request" + placeholders["error_message"] = str(err) except ( exception.B2ConnectionError, exception.B2RequestTimeout, diff --git a/homeassistant/components/backblaze_b2/diagnostics.py b/homeassistant/components/backblaze_b2/diagnostics.py index cf7d7355719..bf7176352d6 100644 --- a/homeassistant/components/backblaze_b2/diagnostics.py +++ b/homeassistant/components/backblaze_b2/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Backblaze B2.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/backblaze_b2/manifest.json b/homeassistant/components/backblaze_b2/manifest.json index 71eed534584..13d3521519b 100644 --- a/homeassistant/components/backblaze_b2/manifest.json +++ b/homeassistant/components/backblaze_b2/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["b2sdk"], "quality_scale": "bronze", - "requirements": ["b2sdk==2.10.1"] + "requirements": ["b2sdk==2.10.4"] } diff --git a/homeassistant/components/backblaze_b2/repairs.py b/homeassistant/components/backblaze_b2/repairs.py index a2747648d2f..21aacc02cbf 100644 --- a/homeassistant/components/backblaze_b2/repairs.py +++ b/homeassistant/components/backblaze_b2/repairs.py @@ -1,7 +1,5 @@ """Repair issues for the Backblaze B2 integration.""" -from __future__ import annotations - import logging from b2sdk.v2.exception import ( diff --git a/homeassistant/components/backblaze_b2/strings.json b/homeassistant/components/backblaze_b2/strings.json index 15bc4a998d2..ce8944a7375 100644 --- a/homeassistant/components/backblaze_b2/strings.json +++ b/homeassistant/components/backblaze_b2/strings.json @@ -6,6 +6,7 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" }, "error": { + "bad_request": "The Backblaze B2 API rejected the request: {error_message}", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_bucket_name": "[%key:component::backblaze_b2::exceptions::invalid_bucket_name::message%]", "invalid_capability": "[%key:component::backblaze_b2::exceptions::invalid_capability::message%]", @@ -60,6 +61,9 @@ } }, "exceptions": { + "bad_request": { + "message": "The Backblaze B2 API rejected the request: {error_message}" + }, "cannot_connect": { "message": "Cannot connect to endpoint" }, diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py index afb4cbf1d18..181e8905b0f 100644 --- a/homeassistant/components/backup/agent.py +++ b/homeassistant/components/backup/agent.py @@ -1,7 +1,5 @@ """Backup agents for the Backup integration.""" -from __future__ import annotations - import abc from collections.abc import AsyncIterator, Callable, Coroutine from pathlib import Path diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py index 3396c7e103f..4573fe0fefd 100644 --- a/homeassistant/components/backup/backup.py +++ b/homeassistant/components/backup/backup.py @@ -1,7 +1,5 @@ """Local backup support for Core and Container installations.""" -from __future__ import annotations - from collections.abc import AsyncIterator, Callable, Coroutine import json from pathlib import Path diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py index e4feb7dd8bd..7cee9ed6e3b 100644 --- a/homeassistant/components/backup/config.py +++ b/homeassistant/components/backup/config.py @@ -1,7 +1,5 @@ """Provide persistent configuration for the backup integration.""" -from __future__ import annotations - from collections import defaultdict from dataclasses import dataclass, field, replace import datetime as dt diff --git a/homeassistant/components/backup/config_flow.py b/homeassistant/components/backup/config_flow.py index ab1f884ea86..fbd3a43de3d 100644 --- a/homeassistant/components/backup/config_flow.py +++ b/homeassistant/components/backup/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Home Assistant Backup integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py index b985283040e..f9719e190f6 100644 --- a/homeassistant/components/backup/const.py +++ b/homeassistant/components/backup/const.py @@ -1,7 +1,5 @@ """Constants for the Backup integration.""" -from __future__ import annotations - from logging import getLogger from typing import TYPE_CHECKING diff --git a/homeassistant/components/backup/coordinator.py b/homeassistant/components/backup/coordinator.py index 1a3429578c2..66489015ad0 100644 --- a/homeassistant/components/backup/coordinator.py +++ b/homeassistant/components/backup/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Home Assistant Backup integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/backup/diagnostics.py b/homeassistant/components/backup/diagnostics.py index 9c3e28bde5b..a009ae957dc 100644 --- a/homeassistant/components/backup/diagnostics.py +++ b/homeassistant/components/backup/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Home Assistant Backup integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/backup/entity.py b/homeassistant/components/backup/entity.py index f07a6a4e4dc..dd735243897 100644 --- a/homeassistant/components/backup/entity.py +++ b/homeassistant/components/backup/entity.py @@ -1,7 +1,5 @@ """Base for backup entities.""" -from __future__ import annotations - from homeassistant.const import __version__ as HA_VERSION from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription diff --git a/homeassistant/components/backup/event.py b/homeassistant/components/backup/event.py index 17c89339148..b2638b875ad 100644 --- a/homeassistant/components/backup/event.py +++ b/homeassistant/components/backup/event.py @@ -1,7 +1,5 @@ """Event platform for Home Assistant Backup integration.""" -from __future__ import annotations - from typing import Final from homeassistant.components.event import EventEntity diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py index b40ea76cd59..92be4aec367 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -1,7 +1,5 @@ """Http view for the Backup integration.""" -from __future__ import annotations - import asyncio from http import HTTPStatus import threading @@ -23,7 +21,7 @@ from . import util from .agent import BackupAgent from .const import DATA_MANAGER from .manager import BackupManager -from .models import AgentBackup, BackupNotFound +from .models import AgentBackup, BackupNotFound, InvalidBackupFilename @callback @@ -195,6 +193,11 @@ class UploadBackupView(HomeAssistantView): backup_id = await manager.async_receive_backup( contents=contents, agent_ids=agent_ids ) + except InvalidBackupFilename as err: + return Response( + body=str(err), + status=HTTPStatus.BAD_REQUEST, + ) except OSError as err: return Response( body=f"Can't write backup file: {err}", diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 520ea8ea38b..701eef048d3 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -1,7 +1,5 @@ """Backup manager for the Backup integration.""" -from __future__ import annotations - import abc import asyncio from collections import defaultdict @@ -12,7 +10,7 @@ import hashlib import io from itertools import chain import json -from pathlib import Path, PurePath +from pathlib import Path, PurePath, PureWindowsPath import shutil import sys import tarfile @@ -68,6 +66,7 @@ from .models import ( BackupReaderWriterError, BaseBackup, Folder, + InvalidBackupFilename, ) from .store import BackupStore from .util import ( @@ -1006,6 +1005,14 @@ class BackupManager: ) -> str: """Receive and store a backup file from upload.""" contents.chunk_size = BUF_SIZE + suggested_filename = contents.filename or "backup.tar" + safe_filename = PureWindowsPath(suggested_filename).name + if ( + not safe_filename + or safe_filename != suggested_filename + or safe_filename == ".." + ): + raise InvalidBackupFilename(f"Invalid filename: {suggested_filename}") self.async_on_backup_event( ReceiveBackupEvent( reason=None, @@ -1016,7 +1023,7 @@ class BackupManager: written_backup = await self._reader_writer.async_receive_backup( agent_ids=agent_ids, stream=contents, - suggested_filename=contents.filename or "backup.tar", + suggested_filename=suggested_filename, ) self.async_on_backup_event( ReceiveBackupEvent( diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index 0c1db47c05f..e72133f1ce4 100644 --- a/homeassistant/components/backup/manifest.json +++ b/homeassistant/components/backup/manifest.json @@ -8,6 +8,6 @@ "integration_type": "service", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["cronsim==2.7", "securetar==2026.2.0"], + "requirements": ["cronsim==2.7", "securetar==2026.4.1"], "single_config_entry": true } diff --git a/homeassistant/components/backup/models.py b/homeassistant/components/backup/models.py index d927cd0bac5..7e0e1a6f98b 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -1,7 +1,5 @@ """Models for the backup integration.""" -from __future__ import annotations - from dataclasses import asdict, dataclass from enum import StrEnum from typing import Any, Self @@ -95,6 +93,12 @@ class BackupReaderWriterError(BackupError): error_code = "backup_reader_writer_error" +class InvalidBackupFilename(BackupManagerError): + """Raised when a backup filename is invalid.""" + + error_code = "invalid_backup_filename" + + class BackupNotFound(BackupAgentError, BackupManagerError): """Raised when a backup is not found.""" diff --git a/homeassistant/components/backup/onboarding.py b/homeassistant/components/backup/onboarding.py index dad0d5e7e35..fd284f9308e 100644 --- a/homeassistant/components/backup/onboarding.py +++ b/homeassistant/components/backup/onboarding.py @@ -1,7 +1,5 @@ """Backup onboarding views.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from functools import wraps from http import HTTPStatus diff --git a/homeassistant/components/backup/sensor.py b/homeassistant/components/backup/sensor.py index 08e7ec49e3d..da61be25e41 100644 --- a/homeassistant/components/backup/sensor.py +++ b/homeassistant/components/backup/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Home Assistant Backup integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime diff --git a/homeassistant/components/backup/services.py b/homeassistant/components/backup/services.py index 17448f7bb06..192a2f3c171 100644 --- a/homeassistant/components/backup/services.py +++ b/homeassistant/components/backup/services.py @@ -2,6 +2,7 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.hassio import is_hassio +from homeassistant.helpers.service import async_register_admin_service from .const import DATA_MANAGER, DOMAIN @@ -30,7 +31,9 @@ async def _async_handle_create_automatic_service(call: ServiceCall) -> None: def async_setup_services(hass: HomeAssistant) -> None: """Register services.""" if not is_hassio(hass): - hass.services.async_register(DOMAIN, "create", _async_handle_create_service) - hass.services.async_register( - DOMAIN, "create_automatic", _async_handle_create_automatic_service + async_register_admin_service( + hass, DOMAIN, "create", _async_handle_create_service + ) + async_register_admin_service( + hass, DOMAIN, "create_automatic", _async_handle_create_automatic_service ) diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py index 94d09e0c53f..e895284d03c 100644 --- a/homeassistant/components/backup/store.py +++ b/homeassistant/components/backup/store.py @@ -1,7 +1,5 @@ """Store backup configuration.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any, TypedDict from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py index d93290d675c..197a1233609 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -1,7 +1,5 @@ """Local backup support for Core and Container installations.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncIterator, Callable, Coroutine import copy @@ -22,6 +20,7 @@ from securetar import ( SecureTarFile, SecureTarReadError, SecureTarRootKeyContext, + get_archive_max_ciphertext_size, ) from homeassistant.core import HomeAssistant @@ -383,9 +382,12 @@ def _encrypt_backup( if prefix not in expected_archives: LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name) continue - output_archive.import_tar( - input_tar.extractfile(obj), obj, derived_key_id=inner_tar_idx - ) + if (fileobj := input_tar.extractfile(obj)) is None: + LOGGER.debug( + "Non regular inner tar file %s will not be encrypted", obj.name + ) + continue + output_archive.import_tar(fileobj, obj, derived_key_id=inner_tar_idx) inner_tar_idx += 1 @@ -419,7 +421,7 @@ class _CipherBackupStreamer: hass: HomeAssistant, backup: AgentBackup, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], - password: str | None, + password: str, ) -> None: """Initialize.""" self._workers: list[_CipherWorkerStatus] = [] @@ -431,7 +433,9 @@ class _CipherBackupStreamer: def size(self) -> int: """Return the maximum size of the decrypted or encrypted backup.""" - return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE + return get_archive_max_ciphertext_size( + self._backup.size, SECURETAR_CREATE_VERSION, self._num_tar_files() + ) def _num_tar_files(self) -> int: """Return the number of inner tar files.""" diff --git a/homeassistant/components/baf/__init__.py b/homeassistant/components/baf/__init__.py index 8d26e3bea43..db32828aa46 100644 --- a/homeassistant/components/baf/__init__.py +++ b/homeassistant/components/baf/__init__.py @@ -1,7 +1,5 @@ """The Big Ass Fans integration.""" -from __future__ import annotations - from asyncio import timeout from aiobafi6 import Device, Service diff --git a/homeassistant/components/baf/binary_sensor.py b/homeassistant/components/baf/binary_sensor.py index e12bfd8b90c..ee2b29b4060 100644 --- a/homeassistant/components/baf/binary_sensor.py +++ b/homeassistant/components/baf/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Big Ass Fans binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import cast diff --git a/homeassistant/components/baf/climate.py b/homeassistant/components/baf/climate.py index abcc2afe254..221d94c0c31 100644 --- a/homeassistant/components/baf/climate.py +++ b/homeassistant/components/baf/climate.py @@ -1,7 +1,5 @@ """Support for Big Ass Fans auto comfort.""" -from __future__ import annotations - from typing import Any from homeassistant.components.climate import ( diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py index 4dbb59165fa..d0beff3a5d8 100644 --- a/homeassistant/components/baf/config_flow.py +++ b/homeassistant/components/baf/config_flow.py @@ -1,7 +1,5 @@ """Config flow for baf.""" -from __future__ import annotations - from asyncio import timeout import logging from typing import Any diff --git a/homeassistant/components/baf/entity.py b/homeassistant/components/baf/entity.py index 6bb9dbfeca7..127f05075d9 100644 --- a/homeassistant/components/baf/entity.py +++ b/homeassistant/components/baf/entity.py @@ -1,7 +1,5 @@ """The baf integration entities.""" -from __future__ import annotations - from aiobafi6 import Device from homeassistant.core import callback diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py index c990a248588..31f39e82e29 100644 --- a/homeassistant/components/baf/fan.py +++ b/homeassistant/components/baf/fan.py @@ -1,7 +1,5 @@ """Support for Big Ass Fans fan.""" -from __future__ import annotations - import math from typing import Any diff --git a/homeassistant/components/baf/light.py b/homeassistant/components/baf/light.py index e8298a8e4d4..8e2a499fa79 100644 --- a/homeassistant/components/baf/light.py +++ b/homeassistant/components/baf/light.py @@ -1,7 +1,5 @@ """Support for Big Ass Fans lights.""" -from __future__ import annotations - from typing import Any from aiobafi6 import Device, OffOnAuto diff --git a/homeassistant/components/baf/models.py b/homeassistant/components/baf/models.py index 3bb574d5a19..8ceceef8020 100644 --- a/homeassistant/components/baf/models.py +++ b/homeassistant/components/baf/models.py @@ -1,7 +1,5 @@ """The baf integration models.""" -from __future__ import annotations - from dataclasses import dataclass diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index 87b5cdc095b..98a50e6b447 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -1,7 +1,5 @@ """Support for Big Ass Fans number.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import cast diff --git a/homeassistant/components/baf/sensor.py b/homeassistant/components/baf/sensor.py index e9b8965b7c4..d9472ac050d 100644 --- a/homeassistant/components/baf/sensor.py +++ b/homeassistant/components/baf/sensor.py @@ -1,7 +1,5 @@ """Support for Big Ass Fans sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import cast diff --git a/homeassistant/components/baf/switch.py b/homeassistant/components/baf/switch.py index 50bd90a6107..4616fd1b4c2 100644 --- a/homeassistant/components/baf/switch.py +++ b/homeassistant/components/baf/switch.py @@ -1,7 +1,5 @@ """Support for Big Ass Fans switch.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any, cast diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py index 54ae569bb78..7e841ce014c 100644 --- a/homeassistant/components/balboa/__init__.py +++ b/homeassistant/components/balboa/__init__.py @@ -1,7 +1,5 @@ """The Balboa Spa Client integration.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py index 437a01866b8..7844e4a3387 100644 --- a/homeassistant/components/balboa/binary_sensor.py +++ b/homeassistant/components/balboa/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Balboa Spa binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py index 3fb2457d610..f0bf25b3e29 100644 --- a/homeassistant/components/balboa/climate.py +++ b/homeassistant/components/balboa/climate.py @@ -1,7 +1,5 @@ """Support for Balboa Spa Wifi adaptor.""" -from __future__ import annotations - from enum import IntEnum from typing import Any diff --git a/homeassistant/components/balboa/config_flow.py b/homeassistant/components/balboa/config_flow.py index 24375ad4e55..14ee684952a 100644 --- a/homeassistant/components/balboa/config_flow.py +++ b/homeassistant/components/balboa/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Balboa Spa Client integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/balboa/entity.py b/homeassistant/components/balboa/entity.py index a541d044a21..df127cde43d 100644 --- a/homeassistant/components/balboa/entity.py +++ b/homeassistant/components/balboa/entity.py @@ -1,7 +1,5 @@ """Balboa entities.""" -from __future__ import annotations - from pybalboa import EVENT_UPDATE, SpaClient from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo diff --git a/homeassistant/components/balboa/event.py b/homeassistant/components/balboa/event.py index 57263c34783..cf566655950 100644 --- a/homeassistant/components/balboa/event.py +++ b/homeassistant/components/balboa/event.py @@ -1,7 +1,5 @@ """Support for Balboa events.""" -from __future__ import annotations - from datetime import datetime, timedelta from pybalboa import EVENT_UPDATE, SpaClient diff --git a/homeassistant/components/balboa/fan.py b/homeassistant/components/balboa/fan.py index b0d4379594b..666cb204961 100644 --- a/homeassistant/components/balboa/fan.py +++ b/homeassistant/components/balboa/fan.py @@ -1,7 +1,5 @@ """Support for Balboa Spa pumps.""" -from __future__ import annotations - import math from typing import Any, cast diff --git a/homeassistant/components/balboa/light.py b/homeassistant/components/balboa/light.py index 2f48747c084..d26e6e47ac0 100644 --- a/homeassistant/components/balboa/light.py +++ b/homeassistant/components/balboa/light.py @@ -1,7 +1,5 @@ """Support for Balboa Spa lights.""" -from __future__ import annotations - from typing import Any, cast from pybalboa import SpaControl diff --git a/homeassistant/components/balboa/switch.py b/homeassistant/components/balboa/switch.py index c8c947f499d..5661f5672bc 100644 --- a/homeassistant/components/balboa/switch.py +++ b/homeassistant/components/balboa/switch.py @@ -1,7 +1,5 @@ """Support for Balboa switches.""" -from __future__ import annotations - from typing import Any from pybalboa import SpaClient diff --git a/homeassistant/components/balboa/time.py b/homeassistant/components/balboa/time.py index 83467de8777..248fefb2c41 100644 --- a/homeassistant/components/balboa/time.py +++ b/homeassistant/components/balboa/time.py @@ -1,7 +1,5 @@ """Support for Balboa times.""" -from __future__ import annotations - from datetime import time import itertools from typing import Any diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index 1668b03b021..90f62b33dc9 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -1,7 +1,5 @@ """The Bang & Olufsen integration.""" -from __future__ import annotations - from dataclasses import dataclass from aiohttp.client_exceptions import ( @@ -21,8 +19,9 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from homeassistant.util.ssl import get_default_context -from .const import DOMAIN +from .const import DOMAIN, MANUFACTURER, BeoModel from .services import async_setup_services +from .util import get_remotes from .websocket import BeoWebsocket @@ -58,15 +57,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool: # Remove casts to str assert entry.unique_id - # Create device now as BeoWebsocket needs a device for debug logging, firing events etc. - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.unique_id)}, - name=entry.title, - model=entry.data[CONF_MODEL], - ) - client = MozartClient(host=entry.data[CONF_HOST], ssl_context=get_default_context()) # Check API and WebSocket connection @@ -83,6 +73,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: BeoConfigEntry) -> bool: await client.close_api_client() raise ConfigEntryNotReady(f"Unable to connect to {entry.title}") from error + # Create device now as BeoWebsocket needs a device for debug logging, firing events etc. + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, entry.unique_id)}, + model=entry.data[CONF_MODEL], + ) + + # Create devices for paired Beoremote One remotes + for remote in await get_remotes(client): + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, f"{remote.serial_number}_{entry.unique_id}")}, + name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{entry.unique_id}", + model=BeoModel.BEOREMOTE_ONE, + serial_number=remote.serial_number, + sw_version=remote.app_version, + manufacturer=MANUFACTURER, + via_device=(DOMAIN, entry.unique_id), + ) + websocket = BeoWebsocket(hass, entry, client) # Add the websocket and API client diff --git a/homeassistant/components/bang_olufsen/binary_sensor.py b/homeassistant/components/bang_olufsen/binary_sensor.py index f90f648d96f..aefa4ae938b 100644 --- a/homeassistant/components/bang_olufsen/binary_sensor.py +++ b/homeassistant/components/bang_olufsen/binary_sensor.py @@ -1,7 +1,5 @@ """Binary Sensor entities for the Bang & Olufsen integration.""" -from __future__ import annotations - from mozart_api.models import BatteryState from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/bang_olufsen/config_flow.py b/homeassistant/components/bang_olufsen/config_flow.py index 62ee08502e2..5202e88356b 100644 --- a/homeassistant/components/bang_olufsen/config_flow.py +++ b/homeassistant/components/bang_olufsen/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Bang & Olufsen integration.""" -from __future__ import annotations - from ipaddress import AddressValueError, IPv4Address from typing import Any, TypedDict @@ -52,6 +50,7 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN): _beolink_jid = "" _client: MozartClient + _friendly_name = "" _host = "" _model = "" _name = "" @@ -111,6 +110,7 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN): ) self._beolink_jid = beolink_self.jid + self._friendly_name = beolink_self.friendly_name self._serial_number = get_serial_number_from_jid(beolink_self.jid) await self.async_set_unique_id(self._serial_number) @@ -149,6 +149,7 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="invalid_address") self._model = discovery_info.hostname[:-16].replace("-", " ") + self._friendly_name = discovery_info.properties[ATTR_FRIENDLY_NAME] self._serial_number = discovery_info.properties[ATTR_SERIAL_NUMBER] self._beolink_jid = f"{discovery_info.properties[ATTR_TYPE_NUMBER]}.{discovery_info.properties[ATTR_ITEM_NUMBER]}.{self._serial_number}@products.bang-olufsen.com" @@ -164,16 +165,13 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN): async def _create_entry(self) -> ConfigFlowResult: """Create the config entry for a discovered or manually configured Bang & Olufsen device.""" - # Ensure that created entities have a unique and easily identifiable id and not a "friendly name" - self._name = f"{self._model}-{self._serial_number}" - return self.async_create_entry( - title=self._name, + title=self._friendly_name, data=EntryData( host=self._host, jid=self._beolink_jid, model=self._model, - name=self._name, + name=self._friendly_name, ), ) diff --git a/homeassistant/components/bang_olufsen/const.py b/homeassistant/components/bang_olufsen/const.py index a029003e34c..9cd1a9b656f 100644 --- a/homeassistant/components/bang_olufsen/const.py +++ b/homeassistant/components/bang_olufsen/const.py @@ -1,7 +1,5 @@ """Constants for the Bang & Olufsen integration.""" -from __future__ import annotations - from enum import StrEnum from typing import Final diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py index b484fdaab05..70f76d574f9 100644 --- a/homeassistant/components/bang_olufsen/diagnostics.py +++ b/homeassistant/components/bang_olufsen/diagnostics.py @@ -1,7 +1,5 @@ """Support for Bang & Olufsen diagnostics.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN diff --git a/homeassistant/components/bang_olufsen/entity.py b/homeassistant/components/bang_olufsen/entity.py index bd24018221c..92ae6ba8318 100644 --- a/homeassistant/components/bang_olufsen/entity.py +++ b/homeassistant/components/bang_olufsen/entity.py @@ -1,7 +1,5 @@ """Entity representing a Bang & Olufsen device.""" -from __future__ import annotations - from typing import cast from mozart_api.models import ( diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py index a14e940b655..f6f89433e56 100644 --- a/homeassistant/components/bang_olufsen/event.py +++ b/homeassistant/components/bang_olufsen/event.py @@ -1,7 +1,5 @@ """Event entities for the Bang & Olufsen integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from mozart_api.models import PairedRemote @@ -20,7 +18,6 @@ from .const import ( CONNECTION_STATUS, DEVICE_BUTTON_EVENTS, DOMAIN, - MANUFACTURER, BeoModel, WebsocketNotification, ) @@ -142,12 +139,6 @@ class BeoRemoteKeyEvent(BeoEvent): self._attr_unique_id = f"{remote.serial_number}_{self._unique_id}_{key_type}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}, - name=f"{BeoModel.BEOREMOTE_ONE}-{remote.serial_number}-{self._unique_id}", - model=BeoModel.BEOREMOTE_ONE, - serial_number=remote.serial_number, - sw_version=remote.app_version, - manufacturer=MANUFACTURER, - via_device=(DOMAIN, self._unique_id), ) # Make the native key name Home Assistant compatible diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 090c2972008..69b240f41fa 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -1,7 +1,5 @@ """Media player entity for the Bang & Olufsen integration.""" -from __future__ import annotations - from collections.abc import Callable import contextlib from datetime import timedelta diff --git a/homeassistant/components/bang_olufsen/sensor.py b/homeassistant/components/bang_olufsen/sensor.py index 9ff703112c3..9c214104b5c 100644 --- a/homeassistant/components/bang_olufsen/sensor.py +++ b/homeassistant/components/bang_olufsen/sensor.py @@ -1,7 +1,5 @@ """Sensor entities for the Bang & Olufsen integration.""" -from __future__ import annotations - import contextlib from datetime import timedelta @@ -115,7 +113,7 @@ class BeoSensorRemoteBatteryLevel(BeoSensor): f"{remote.serial_number}_{self._unique_id}_remote_battery_level" ) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")} + identifiers={(DOMAIN, f"{remote.serial_number}_{self._unique_id}")}, ) self._attr_native_value = remote.battery_level self._remote = remote diff --git a/homeassistant/components/bang_olufsen/services.py b/homeassistant/components/bang_olufsen/services.py index a0f9f278ec0..db98053306a 100644 --- a/homeassistant/components/bang_olufsen/services.py +++ b/homeassistant/components/bang_olufsen/services.py @@ -1,7 +1,5 @@ """Services for Bang & Olufsen 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/bang_olufsen/util.py b/homeassistant/components/bang_olufsen/util.py index 540837441a4..d869c63efa2 100644 --- a/homeassistant/components/bang_olufsen/util.py +++ b/homeassistant/components/bang_olufsen/util.py @@ -1,7 +1,5 @@ """Various utilities for the Bang & Olufsen integration.""" -from __future__ import annotations - from typing import cast from mozart_api.models import PairedRemote @@ -34,7 +32,7 @@ def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry: def get_serial_number_from_jid(jid: str) -> str: """Get serial number from Beolink JID.""" - return jid.split(".")[2].split("@")[0] + return jid.split(".")[2].split("@", maxsplit=1)[0] async def get_remotes(client: MozartClient) -> list[PairedRemote]: diff --git a/homeassistant/components/bang_olufsen/websocket.py b/homeassistant/components/bang_olufsen/websocket.py index 09e27ea5f88..b1c4939d3df 100644 --- a/homeassistant/components/bang_olufsen/websocket.py +++ b/homeassistant/components/bang_olufsen/websocket.py @@ -1,7 +1,5 @@ """Update coordinator and WebSocket listener(s) for the Bang & Olufsen integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/battery/__init__.py b/homeassistant/components/battery/__init__.py index 52644072bba..417438e34f0 100644 --- a/homeassistant/components/battery/__init__.py +++ b/homeassistant/components/battery/__init__.py @@ -1,7 +1,5 @@ """Integration for battery 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/battery/condition.py b/homeassistant/components/battery/condition.py index 60f479aa4af..b07d544413a 100644 --- a/homeassistant/components/battery/condition.py +++ b/homeassistant/components/battery/condition.py @@ -1,7 +1,5 @@ """Provides conditions for batteries.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, @@ -29,14 +27,30 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS = { } CONDITIONS: dict[str, type[Condition]] = { - "is_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_ON), - "is_not_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_OFF), - "is_charging": make_entity_state_condition(BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON), + "is_low": make_entity_state_condition( + BATTERY_DOMAIN_SPECS, + STATE_ON, + primary_entities_only=False, + ), + "is_not_low": make_entity_state_condition( + BATTERY_DOMAIN_SPECS, + STATE_OFF, + primary_entities_only=False, + ), + "is_charging": make_entity_state_condition( + BATTERY_CHARGING_DOMAIN_SPECS, + STATE_ON, + primary_entities_only=False, + ), "is_not_charging": make_entity_state_condition( - BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF + BATTERY_CHARGING_DOMAIN_SPECS, + STATE_OFF, + primary_entities_only=False, ), "is_level": make_entity_numerical_condition( - BATTERY_PERCENTAGE_DOMAIN_SPECS, PERCENTAGE + BATTERY_PERCENTAGE_DOMAIN_SPECS, + PERCENTAGE, + primary_entities_only=False, ), } diff --git a/homeassistant/components/battery/conditions.yaml b/homeassistant/components/battery/conditions.yaml index 9bd7c1f3596..4946248ad1b 100644 --- a/homeassistant/components/battery/conditions.yaml +++ b/homeassistant/components/battery/conditions.yaml @@ -3,16 +3,19 @@ entity: - domain: binary_sensor device_class: battery + primary_entities_only: false fields: 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: .battery_threshold_entity: &battery_threshold_entity - domain: input_number @@ -37,24 +40,30 @@ is_charging: entity: - domain: binary_sensor device_class: battery_charging + primary_entities_only: false fields: behavior: *condition_behavior + for: *condition_for is_not_charging: target: entity: - domain: binary_sensor device_class: battery_charging + primary_entities_only: false fields: behavior: *condition_behavior + for: *condition_for is_level: target: entity: - domain: sensor device_class: battery + primary_entities_only: false fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: diff --git a/homeassistant/components/battery/strings.json b/homeassistant/components/battery/strings.json index dc6c518f665..078ffacae04 100644 --- a/homeassistant/components/battery/strings.json +++ b/homeassistant/components/battery/strings.json @@ -1,22 +1,21 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted batteries.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted batteries to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "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": { "is_charging": { "description": "Tests if one or more batteries are charging.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::condition_for_name%]" } }, "name": "Battery is charging" @@ -25,11 +24,12 @@ "description": "Tests the battery level of one or more batteries.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::battery::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::battery::common::condition_threshold_description%]", "name": "[%key:component::battery::common::condition_threshold_name%]" } }, @@ -39,8 +39,10 @@ "description": "Tests if one or more batteries are low.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::condition_for_name%]" } }, "name": "Battery is low" @@ -49,8 +51,10 @@ "description": "Tests if one or more batteries are not charging.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::condition_for_name%]" } }, "name": "Battery is not charging" @@ -59,35 +63,21 @@ "description": "Tests if one or more batteries are not low.", "fields": { "behavior": { - "description": "[%key:component::battery::common::condition_behavior_description%]", "name": "[%key:component::battery::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::condition_for_name%]" } }, "name": "Battery is not low" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Battery", "triggers": { "level_changed": { "description": "Triggers after the battery level of one or more batteries changes.", "fields": { "threshold": { - "description": "[%key:component::battery::common::trigger_threshold_changed_description%]", "name": "[%key:component::battery::common::trigger_threshold_name%]" } }, @@ -97,11 +87,12 @@ "description": "Triggers after the battery level of one or more batteries crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::battery::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::battery::common::trigger_threshold_crossed_description%]", "name": "[%key:component::battery::common::trigger_threshold_name%]" } }, @@ -111,8 +102,10 @@ "description": "Triggers after one or more batteries become low.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::trigger_for_name%]" } }, "name": "Battery low" @@ -121,8 +114,10 @@ "description": "Triggers after one or more batteries are no longer low.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::trigger_for_name%]" } }, "name": "Battery not low" @@ -131,8 +126,10 @@ "description": "Triggers after one or more batteries start charging.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::trigger_for_name%]" } }, "name": "Battery started charging" @@ -141,8 +138,10 @@ "description": "Triggers after one or more batteries stop charging.", "fields": { "behavior": { - "description": "[%key:component::battery::common::trigger_behavior_description%]", "name": "[%key:component::battery::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::trigger_for_name%]" } }, "name": "Battery stopped charging" diff --git a/homeassistant/components/battery/trigger.py b/homeassistant/components/battery/trigger.py index 426dae82569..8ad26239ddc 100644 --- a/homeassistant/components/battery/trigger.py +++ b/homeassistant/components/battery/trigger.py @@ -1,7 +1,5 @@ """Provides triggers for batteries.""" -from __future__ import annotations - from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, @@ -32,19 +30,27 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS: dict[str, DomainSpec] = { } TRIGGERS: dict[str, type[Trigger]] = { - "low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_ON), - "not_low": make_entity_target_state_trigger(BATTERY_LOW_DOMAIN_SPECS, STATE_OFF), + "low": make_entity_target_state_trigger( + BATTERY_LOW_DOMAIN_SPECS, STATE_ON, primary_entities_only=False + ), + "not_low": make_entity_target_state_trigger( + BATTERY_LOW_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False + ), "started_charging": make_entity_target_state_trigger( - BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON + BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, primary_entities_only=False ), "stopped_charging": make_entity_target_state_trigger( - BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF + BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, primary_entities_only=False ), "level_changed": make_entity_numerical_state_changed_trigger( - BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%" + BATTERY_PERCENTAGE_DOMAIN_SPECS, + valid_unit="%", + primary_entities_only=False, ), "level_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - BATTERY_PERCENTAGE_DOMAIN_SPECS, valid_unit="%" + BATTERY_PERCENTAGE_DOMAIN_SPECS, + valid_unit="%", + primary_entities_only=False, ), } diff --git a/homeassistant/components/battery/triggers.yaml b/homeassistant/components/battery/triggers.yaml index 2ca59cf423f..2d6ef389b50 100644 --- a/homeassistant/components/battery/triggers.yaml +++ b/homeassistant/components/battery/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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: .battery_threshold_entity: &battery_threshold_entity - domain: input_number @@ -28,35 +29,42 @@ entity: - domain: binary_sensor device_class: battery + primary_entities_only: false .trigger_target_charging: &trigger_target_charging entity: - domain: binary_sensor device_class: battery_charging + primary_entities_only: false .trigger_target_percentage: &trigger_target_percentage entity: - domain: sensor device_class: battery + primary_entities_only: false low: fields: behavior: *trigger_behavior + for: *trigger_for target: *trigger_target_battery not_low: fields: behavior: *trigger_behavior + for: *trigger_for target: *trigger_target_battery started_charging: fields: behavior: *trigger_behavior + for: *trigger_for target: *trigger_target_charging stopped_charging: fields: behavior: *trigger_behavior + for: *trigger_for target: *trigger_target_charging level_changed: @@ -74,6 +82,7 @@ level_crossed_threshold: target: *trigger_target_percentage fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 6d3dbb7f244..6d74c6d4fa8 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -1,7 +1,5 @@ """Use Bayesian Inference to trigger a binary sensor.""" -from __future__ import annotations - from collections import OrderedDict from collections.abc import Callable import logging diff --git a/homeassistant/components/bayesian/config_flow.py b/homeassistant/components/bayesian/config_flow.py index ce13cf43d8c..c43305ef998 100644 --- a/homeassistant/components/bayesian/config_flow.py +++ b/homeassistant/components/bayesian/config_flow.py @@ -33,11 +33,13 @@ from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.config_entries import ( + SOURCE_USER, ConfigEntry, ConfigFlowResult, ConfigSubentry, - ConfigSubentryData, ConfigSubentryFlow, + FlowType, + SubentryFlowContext, SubentryFlowResult, ) from homeassistant.const import ( @@ -62,7 +64,6 @@ from homeassistant.helpers.schema_config_entry_flow import ( from .binary_sensor import above_greater_than_below, no_overlapping from .const import ( - CONF_OBSERVATIONS, CONF_P_GIVEN_F, CONF_P_GIVEN_T, CONF_PRIOR, @@ -373,26 +374,6 @@ def _validate_observation_subentry( return user_input -async def _validate_subentry_from_config_entry( - handler: SchemaCommonFlowHandler, user_input: dict[str, Any] -) -> dict[str, Any]: - # Standard behavior is to merge the result with the options. - # In this case, we want to add a subentry so we update the options directly. - observations: list[dict[str, Any]] = handler.options.setdefault( - CONF_OBSERVATIONS, [] - ) - - if handler.parent_handler.cur_step is not None: - user_input[CONF_PLATFORM] = handler.parent_handler.cur_step["step_id"] - user_input = _validate_observation_subentry( - user_input[CONF_PLATFORM], - user_input, - other_subentries=handler.options[CONF_OBSERVATIONS], - ) - observations.append(user_input) - return {} - - async def _get_description_placeholders( handler: SchemaCommonFlowHandler, ) -> dict[str, str]: @@ -420,48 +401,12 @@ async def _get_description_placeholders( } -async def _get_observation_menu_options(handler: SchemaCommonFlowHandler) -> list[str]: - """Return the menu options for the observation selector.""" - options = [typ.value for typ in ObservationTypes] - if handler.options.get(CONF_OBSERVATIONS): - options.append("finish") - return options - - CONFIG_FLOW: dict[str, SchemaFlowMenuStep | SchemaFlowFormStep] = { str(USER): SchemaFlowFormStep( CONFIG_SCHEMA, validate_user_input=_validate_user, - next_step=str(OBSERVATION_SELECTOR), description_placeholders=_get_description_placeholders, - ), - str(OBSERVATION_SELECTOR): SchemaFlowMenuStep( - _get_observation_menu_options, - ), - str(ObservationTypes.STATE): SchemaFlowFormStep( - STATE_SUBSCHEMA, - next_step=str(OBSERVATION_SELECTOR), - validate_user_input=_validate_subentry_from_config_entry, - # Prevent the name of the bayesian sensor from being used as the suggested - # name of the observations - suggested_values=None, - description_placeholders=_get_description_placeholders, - ), - str(ObservationTypes.NUMERIC_STATE): SchemaFlowFormStep( - NUMERIC_STATE_SUBSCHEMA, - next_step=str(OBSERVATION_SELECTOR), - validate_user_input=_validate_subentry_from_config_entry, - suggested_values=None, - description_placeholders=_get_description_placeholders, - ), - str(ObservationTypes.TEMPLATE): SchemaFlowFormStep( - TEMPLATE_SUBSCHEMA, - next_step=str(OBSERVATION_SELECTOR), - validate_user_input=_validate_subentry_from_config_entry, - suggested_values=None, - description_placeholders=_get_description_placeholders, - ), - "finish": SchemaFlowFormStep(), + ) } @@ -497,27 +442,17 @@ class BayesianConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): name: str = options[CONF_NAME] return name - @callback - def async_create_entry( - self, - data: Mapping[str, Any], - **kwargs: Any, - ) -> ConfigFlowResult: - """Finish config flow and create a config entry.""" - data = dict(data) - observations = data.pop(CONF_OBSERVATIONS) - subentries: list[ConfigSubentryData] = [ - ConfigSubentryData( - data=observation, - title=observation[CONF_NAME], - subentry_type="observation", - unique_id=None, - ) - for observation in observations - ] - - self.async_config_flow_finished(data) - return super().async_create_entry(data=data, subentries=subentries, **kwargs) + async def async_on_create_entry(self, result: ConfigFlowResult) -> ConfigFlowResult: + """Start subentry flow when config entry has been created.""" + subentry_result = await self.hass.config_entries.subentries.async_init( + (result["result"].entry_id, "observation"), + context=SubentryFlowContext(source=SOURCE_USER), + ) + result["next_flow"] = ( + FlowType.CONFIG_SUBENTRIES_FLOW, + subentry_result["flow_id"], + ) + return result class ObservationSubentryFlowHandler(ConfigSubentryFlow): diff --git a/homeassistant/components/bayesian/helpers.py b/homeassistant/components/bayesian/helpers.py index 2af3a331775..84452924252 100644 --- a/homeassistant/components/bayesian/helpers.py +++ b/homeassistant/components/bayesian/helpers.py @@ -1,7 +1,5 @@ """Helpers to deal with bayesian observations.""" -from __future__ import annotations - from dataclasses import dataclass, field import uuid diff --git a/homeassistant/components/bayesian/issues.py b/homeassistant/components/bayesian/issues.py index 35080949c6f..01421f03efd 100644 --- a/homeassistant/components/bayesian/issues.py +++ b/homeassistant/components/bayesian/issues.py @@ -1,7 +1,5 @@ """Helpers for generating issues.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir diff --git a/homeassistant/components/bbox/__init__.py b/homeassistant/components/bbox/__init__.py index 8c3bbf0d57f..ccbd46cb9a5 100644 --- a/homeassistant/components/bbox/__init__.py +++ b/homeassistant/components/bbox/__init__.py @@ -1 +1 @@ -"""The bbox component.""" +"""The Bbox integration.""" diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py index 18b62f2a506..ceae76b69ac 100644 --- a/homeassistant/components/bbox/device_tracker.py +++ b/homeassistant/components/bbox/device_tracker.py @@ -1,7 +1,5 @@ """Support for French FAI Bouygues Bbox routers.""" -from __future__ import annotations - from collections import namedtuple from datetime import timedelta import logging diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py index fed059247d0..5eb55667de3 100644 --- a/homeassistant/components/bbox/sensor.py +++ b/homeassistant/components/bbox/sensor.py @@ -1,7 +1,5 @@ """Support for Bbox Bouygues Modem Router.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py index 3a0a6f21f98..77a7472cbd2 100644 --- a/homeassistant/components/beewi_smartclim/sensor.py +++ b/homeassistant/components/beewi_smartclim/sensor.py @@ -1,7 +1,5 @@ """Platform for beewi_smartclim integration.""" -from __future__ import annotations - from beewi_smartclim import BeewiSmartClimPoller import voluptuous as vol diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 7b0c121ac6b..dc06c382003 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -1,7 +1,5 @@ """Component to interface with binary sensors.""" -from __future__ import annotations - from datetime import timedelta from enum import StrEnum import logging diff --git a/homeassistant/components/binary_sensor/device_condition.py b/homeassistant/components/binary_sensor/device_condition.py index bbd80959b12..1829a0a5ab0 100644 --- a/homeassistant/components/binary_sensor/device_condition.py +++ b/homeassistant/components/binary_sensor/device_condition.py @@ -1,7 +1,5 @@ """Implement device conditions for binary sensor.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import CONF_IS_OFF, CONF_IS_ON diff --git a/homeassistant/components/binary_sensor/significant_change.py b/homeassistant/components/binary_sensor/significant_change.py index 4801af1f54d..31d3a0b6ab7 100644 --- a/homeassistant/components/binary_sensor/significant_change.py +++ b/homeassistant/components/binary_sensor/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Binary Sensor state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/bitcoin/__init__.py b/homeassistant/components/bitcoin/__init__.py index cfdfb53c044..830541f830e 100644 --- a/homeassistant/components/bitcoin/__init__.py +++ b/homeassistant/components/bitcoin/__init__.py @@ -1 +1 @@ -"""The bitcoin component.""" +"""The Bitcoin integration.""" diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index f350c1c0b58..f499a679a0d 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -1,7 +1,5 @@ """Bitcoin information service that uses blockchain.com.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py index 085c0093073..0f2a4d6db3b 100644 --- a/homeassistant/components/bizkaibus/sensor.py +++ b/homeassistant/components/bizkaibus/sensor.py @@ -1,7 +1,5 @@ """Support for Bizkaibus, Biscay (Basque Country, Spain) Bus service.""" -from __future__ import annotations - from contextlib import suppress from bizkaibus.bizkaibus import BizkaibusData diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py index 2d39512cbe0..844df3119d8 100644 --- a/homeassistant/components/blackbird/media_player.py +++ b/homeassistant/components/blackbird/media_player.py @@ -1,7 +1,5 @@ """Support for interfacing with Monoprice Blackbird 4k 8x8 HDBaseT Matrix.""" -from __future__ import annotations - import logging from pyblackbird import get_blackbird diff --git a/homeassistant/components/blebox/button.py b/homeassistant/components/blebox/button.py index 15867f84029..5fae765ec3a 100644 --- a/homeassistant/components/blebox/button.py +++ b/homeassistant/components/blebox/button.py @@ -1,7 +1,5 @@ """BleBox button entities implementation.""" -from __future__ import annotations - import blebox_uniapi.button from homeassistant.components.button import ButtonEntity diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index 523b5af793f..ed8999cf7d9 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -1,7 +1,5 @@ """Config flow for BleBox devices integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index c52c551bbac..444713d5b17 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -1,7 +1,5 @@ """BleBox cover entity.""" -from __future__ import annotations - from typing import Any import blebox_uniapi.cover @@ -85,7 +83,9 @@ class BleBoxCoverEntity(BleBoxEntity[blebox_uniapi.cover.Cover], CoverEntity): if position == -1: # possible for shutterBox return None - return None if position is None else 100 - position + if position is None: + return None + return 100 - position if self._feature.is_position_inverted else position @property def current_cover_tilt_position(self) -> int | None: diff --git a/homeassistant/components/blebox/helpers.py b/homeassistant/components/blebox/helpers.py index 8061fff5645..98b3e777779 100644 --- a/homeassistant/components/blebox/helpers.py +++ b/homeassistant/components/blebox/helpers.py @@ -1,7 +1,5 @@ """Blebox helpers.""" -from __future__ import annotations - import aiohttp from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index 4db64d998f5..9b05ca1526a 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -1,7 +1,5 @@ """BleBox light entities implementation.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index 19a8a06c835..885cfb81038 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -1,12 +1,12 @@ { "domain": "blebox", "name": "BleBox devices", - "codeowners": ["@bbx-a", "@swistakm"], + "codeowners": ["@bbx-a", "@swistakm", "@bkobus-bbx"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blebox", "integration_type": "device", "iot_class": "local_polling", "loggers": ["blebox_uniapi"], - "requirements": ["blebox-uniapi==2.5.0"], + "requirements": ["blebox-uniapi==2.5.2"], "zeroconf": ["_bbxsrv._tcp.local."] } diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py index 14cddf41e45..8570c4b29e1 100644 --- a/homeassistant/components/blebox/sensor.py +++ b/homeassistant/components/blebox/sensor.py @@ -1,6 +1,6 @@ """BleBox sensor entities.""" -from datetime import datetime +from datetime import datetime, timedelta import blebox_uniapi.sensor @@ -30,6 +30,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BleBoxConfigEntry from .entity import BleBoxEntity +SCAN_INTERVAL = timedelta(seconds=5) + + SENSOR_TYPES = ( SensorEntityDescription( key="pm1", @@ -53,9 +56,9 @@ SENSOR_TYPES = ( ), SensorEntityDescription( key="powerConsumption", - device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL, + suggested_display_precision=2, + icon="mdi:lightning-bolt", ), SensorEntityDescription( key="humidity", @@ -150,6 +153,7 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn @property def last_reset(self) -> datetime | None: """Return the time when the sensor was last reset, if implemented.""" + if self.state_class != SensorStateClass.TOTAL: + return None native_implementation = getattr(self._feature, "last_reset", None) - return native_implementation or super().last_reset diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 9a6de387150..8550ddff8ee 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Blink Alarm Control Panel.""" -from __future__ import annotations - import logging from blinkpy.auth import UnauthorizedError diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 3d5430c8d1f..1366eabbb7d 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Blink system camera control.""" -from __future__ import annotations - import logging from homeassistant.components.binary_sensor import ( diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 0ebef162795..a5b1b05c410 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -1,7 +1,5 @@ """Support for Blink system camera.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index 896226327af..d334d9000d9 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure Blink.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/blink/coordinator.py b/homeassistant/components/blink/coordinator.py index 032b36ed0a4..2819e3380c6 100644 --- a/homeassistant/components/blink/coordinator.py +++ b/homeassistant/components/blink/coordinator.py @@ -1,7 +1,5 @@ """Blink Coordinator.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any diff --git a/homeassistant/components/blink/diagnostics.py b/homeassistant/components/blink/diagnostics.py index 255f58fc369..735e611affb 100644 --- a/homeassistant/components/blink/diagnostics.py +++ b/homeassistant/components/blink/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Blink.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/blink/icons.json b/homeassistant/components/blink/icons.json index 1f4edb07f42..44f9c4561c1 100644 --- a/homeassistant/components/blink/icons.json +++ b/homeassistant/components/blink/icons.json @@ -21,9 +21,6 @@ "save_video": { "service": "mdi:file-video" }, - "send_pin": { - "service": "mdi:two-factor-authentication" - }, "trigger_camera": { "service": "mdi:image-refresh" } diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index 1df708c3a10..83f5353a0cc 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -1,7 +1,5 @@ """Support for Blink system camera sensors.""" -from __future__ import annotations - import logging from homeassistant.components.sensor import ( diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 3882aa67312..a93c60a2002 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -1,19 +1,11 @@ """Services for the Blink integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN -from homeassistant.const import ( - ATTR_CONFIG_ENTRY_ID, - CONF_FILE_PATH, - CONF_FILENAME, - CONF_PIN, -) -from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, issue_registry as ir, service +from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service from .const import DOMAIN @@ -23,50 +15,10 @@ SERVICE_SAVE_VIDEO = "save_video" SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips" -# Deprecated -SERVICE_SEND_PIN = "send_pin" -SERVICE_SEND_PIN_SCHEMA = vol.Schema( - { - vol.Required(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_PIN): cv.string, - } -) - - -async def _send_pin(call: ServiceCall) -> None: - """Call blink to send new pin.""" - # Create repair issue to inform user about service removal - ir.async_create_issue( - call.hass, - DOMAIN, - "service_send_pin_deprecation", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.ERROR, - breaks_in_ha_version="2026.5.0", - translation_key="service_send_pin_deprecation", - translation_placeholders={"service_name": f"{DOMAIN}.{SERVICE_SEND_PIN}"}, - ) - - # Service has been removed - raise exception - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_removed", - translation_placeholders={"service_name": f"{DOMAIN}.{SERVICE_SEND_PIN}"}, - ) - - @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Blink integration.""" - hass.services.async_register( - DOMAIN, - SERVICE_SEND_PIN, - _send_pin, - schema=SERVICE_SEND_PIN_SCHEMA, - ) - service.async_register_platform_entity_service( hass, DOMAIN, diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml index 244763d5535..82aeafb8ede 100644 --- a/homeassistant/components/blink/services.yaml +++ b/homeassistant/components/blink/services.yaml @@ -35,15 +35,3 @@ save_recent_clips: example: "/tmp" selector: text: - -send_pin: - fields: - config_entry_id: - required: true - selector: - config_entry: - integration: blink - pin: - example: "abc123" - selector: - text: diff --git a/homeassistant/components/blink/strings.json b/homeassistant/components/blink/strings.json index cdd30483b50..7bf075f18c9 100644 --- a/homeassistant/components/blink/strings.json +++ b/homeassistant/components/blink/strings.json @@ -82,9 +82,6 @@ }, "not_loaded": { "message": "{target} is not loaded." - }, - "service_removed": { - "message": "The service {service_name} has been removed and is no longer needed. Home Assistant will automatically prompt for reauthentication when required." } }, "issues": { @@ -98,10 +95,6 @@ } }, "title": "Blink update service is being removed" - }, - "service_send_pin_deprecation": { - "description": "The service {service_name} has been removed and is no longer needed. When a new two-factor authentication code is required, Home Assistant will automatically prompt you to reauthenticate through the integration configuration. Please remove any automations or scripts that call this service.", - "title": "Blink send PIN service has been removed" } }, "options": { @@ -140,20 +133,6 @@ }, "name": "Save video" }, - "send_pin": { - "description": "Sends a new PIN to Blink for 2FA.", - "fields": { - "config_entry_id": { - "description": "The Blink integration ID.", - "name": "Integration ID" - }, - "pin": { - "description": "PIN received from Blink. Leave empty if you only received a verification email.", - "name": "PIN" - } - }, - "name": "Send PIN" - }, "trigger_camera": { "description": "Requests camera to take new image.", "name": "Trigger camera" diff --git a/homeassistant/components/blink/switch.py b/homeassistant/components/blink/switch.py index fd991845312..f98baffd296 100644 --- a/homeassistant/components/blink/switch.py +++ b/homeassistant/components/blink/switch.py @@ -1,7 +1,5 @@ """Support for Blink Motion detection switches.""" -from __future__ import annotations - from typing import Any from blinkpy.auth import UnauthorizedError diff --git a/homeassistant/components/blinksticklight/__init__.py b/homeassistant/components/blinksticklight/__init__.py index dd45fbcd690..06375f0ab69 100644 --- a/homeassistant/components/blinksticklight/__init__.py +++ b/homeassistant/components/blinksticklight/__init__.py @@ -1 +1 @@ -"""The blinksticklight component.""" +"""The BlinkStick integration.""" diff --git a/homeassistant/components/blinksticklight/light.py b/homeassistant/components/blinksticklight/light.py index 01e5c90aadf..e3af9cdaadc 100644 --- a/homeassistant/components/blinksticklight/light.py +++ b/homeassistant/components/blinksticklight/light.py @@ -1,8 +1,6 @@ -"""Support for Blinkstick lights.""" +"""Support for BlinkStick lights.""" # mypy: ignore-errors -from __future__ import annotations - from typing import Any # from blinkstick import blinkstick @@ -40,7 +38,7 @@ def setup_platform( add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up Blinkstick device specified by serial number.""" + """Set up BlinkStick device specified by serial number.""" name = config[CONF_NAME] serial = config[CONF_SERIAL] diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py index a6aedb2c472..816c23628bf 100644 --- a/homeassistant/components/blockchain/sensor.py +++ b/homeassistant/components/blockchain/sensor.py @@ -1,7 +1,5 @@ """Support for Blockchain.com sensors.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/blue_current/__init__.py b/homeassistant/components/blue_current/__init__.py index 73255ae7669..4b9c328f7da 100644 --- a/homeassistant/components/blue_current/__init__.py +++ b/homeassistant/components/blue_current/__init__.py @@ -1,7 +1,5 @@ """The Blue Current integration.""" -from __future__ import annotations - import asyncio from contextlib import suppress from typing import Any diff --git a/homeassistant/components/blue_current/button.py b/homeassistant/components/blue_current/button.py index 9d2cde547ca..411fff27d4a 100644 --- a/homeassistant/components/blue_current/button.py +++ b/homeassistant/components/blue_current/button.py @@ -1,7 +1,5 @@ """Support for Blue Current buttons.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/blue_current/config_flow.py b/homeassistant/components/blue_current/config_flow.py index c8593b7d51c..0240716891a 100644 --- a/homeassistant/components/blue_current/config_flow.py +++ b/homeassistant/components/blue_current/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Blue Current integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/blue_current/sensor.py b/homeassistant/components/blue_current/sensor.py index 9ea444f9ec2..b9dcf96c63c 100644 --- a/homeassistant/components/blue_current/sensor.py +++ b/homeassistant/components/blue_current/sensor.py @@ -1,7 +1,5 @@ """Support for Blue Current sensors.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, diff --git a/homeassistant/components/blue_current/services.py b/homeassistant/components/blue_current/services.py index 8cd133ccdef..d92636bd23e 100644 --- a/homeassistant/components/blue_current/services.py +++ b/homeassistant/components/blue_current/services.py @@ -1,7 +1,5 @@ """The Blue Current integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState diff --git a/homeassistant/components/blue_current/switch.py b/homeassistant/components/blue_current/switch.py index 3ca32e34055..a28b2c3b7d5 100644 --- a/homeassistant/components/blue_current/switch.py +++ b/homeassistant/components/blue_current/switch.py @@ -1,7 +1,5 @@ """Support for Blue Current switches.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/bluemaestro/__init__.py b/homeassistant/components/bluemaestro/__init__.py index 3d358148fab..817fb737d2c 100644 --- a/homeassistant/components/bluemaestro/__init__.py +++ b/homeassistant/components/bluemaestro/__init__.py @@ -1,7 +1,5 @@ """The BlueMaestro integration.""" -from __future__ import annotations - import logging from bluemaestro_ble import BlueMaestroBluetoothDeviceData diff --git a/homeassistant/components/bluemaestro/config_flow.py b/homeassistant/components/bluemaestro/config_flow.py index 6f4898d260f..08c4b2de1fc 100644 --- a/homeassistant/components/bluemaestro/config_flow.py +++ b/homeassistant/components/bluemaestro/config_flow.py @@ -1,7 +1,5 @@ """Config flow for bluemaestro ble integration.""" -from __future__ import annotations - from typing import Any from bluemaestro_ble import BlueMaestroBluetoothDeviceData as DeviceData diff --git a/homeassistant/components/bluemaestro/device.py b/homeassistant/components/bluemaestro/device.py index 2d1a33347c3..1686425d84a 100644 --- a/homeassistant/components/bluemaestro/device.py +++ b/homeassistant/components/bluemaestro/device.py @@ -1,7 +1,5 @@ """Support for BlueMaestro devices.""" -from __future__ import annotations - from bluemaestro_ble import DeviceKey from homeassistant.components.bluetooth.passive_update_processor import ( diff --git a/homeassistant/components/bluemaestro/sensor.py b/homeassistant/components/bluemaestro/sensor.py index 1163f8a1ff6..d7d8d26a803 100644 --- a/homeassistant/components/bluemaestro/sensor.py +++ b/homeassistant/components/bluemaestro/sensor.py @@ -1,7 +1,5 @@ """Support for BlueMaestro sensors.""" -from __future__ import annotations - from bluemaestro_ble import ( SensorDeviceClass as BlueMaestroSensorDeviceClass, SensorUpdate, diff --git a/homeassistant/components/blueprint/errors.py b/homeassistant/components/blueprint/errors.py index e9a9defe05a..2261df212b9 100644 --- a/homeassistant/components/blueprint/errors.py +++ b/homeassistant/components/blueprint/errors.py @@ -1,7 +1,5 @@ """Blueprint errors.""" -from __future__ import annotations - from collections.abc import Iterable from typing import Any diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py index 83afa511b68..380a83028be 100644 --- a/homeassistant/components/blueprint/importer.py +++ b/homeassistant/components/blueprint/importer.py @@ -1,7 +1,5 @@ """Import logic for blueprint.""" -from __future__ import annotations - from contextlib import suppress from dataclasses import dataclass import html diff --git a/homeassistant/components/blueprint/models.py b/homeassistant/components/blueprint/models.py index 88052100259..3af1acfa014 100644 --- a/homeassistant/components/blueprint/models.py +++ b/homeassistant/components/blueprint/models.py @@ -1,7 +1,5 @@ """Blueprint models.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable import logging diff --git a/homeassistant/components/blueprint/websocket_api.py b/homeassistant/components/blueprint/websocket_api.py index 873e3b30a36..054cdc3452c 100644 --- a/homeassistant/components/blueprint/websocket_api.py +++ b/homeassistant/components/blueprint/websocket_api.py @@ -1,7 +1,5 @@ """Websocket API for blueprint.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine import functools diff --git a/homeassistant/components/bluesound/button.py b/homeassistant/components/bluesound/button.py index 4c9d363fa5f..7b3925ad679 100644 --- a/homeassistant/components/bluesound/button.py +++ b/homeassistant/components/bluesound/button.py @@ -1,7 +1,5 @@ """Button entities for Bluesound.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/bluesound/coordinator.py b/homeassistant/components/bluesound/coordinator.py index ceaf0b392eb..9932212296a 100644 --- a/homeassistant/components/bluesound/coordinator.py +++ b/homeassistant/components/bluesound/coordinator.py @@ -1,7 +1,5 @@ """Define a base coordinator for Bluesound entities.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine import contextlib diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index fd09be71601..e772f80ca9c 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -1,7 +1,5 @@ """Support for Bluesound devices.""" -from __future__ import annotations - from asyncio import Task from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 941a7822439..56e54bd9c3f 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -1,7 +1,5 @@ """The bluetooth integration.""" -from __future__ import annotations - import datetime import logging import platform @@ -58,6 +56,7 @@ from .api import ( async_address_present, async_ble_device_from_address, async_clear_address_from_match_history, + async_clear_advertisement_history, async_current_scanners, async_discovered_service_info, async_get_advertisement_callback, @@ -116,6 +115,7 @@ __all__ = [ "async_address_present", "async_ble_device_from_address", "async_clear_address_from_match_history", + "async_clear_advertisement_history", "async_current_scanners", "async_discovered_service_info", "async_get_advertisement_callback", diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index 03c278d6b0d..1cbd94a7774 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -3,8 +3,6 @@ Receives data from advertisements but can also poll. """ -from __future__ import annotations - from collections.abc import Callable, Coroutine import logging from typing import Any diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index 8a23de682e6..2ecf4345958 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -3,8 +3,6 @@ Collects data from advertisements but can also poll. """ -from __future__ import annotations - from collections.abc import Callable, Coroutine import logging from typing import Any diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index c0ec6acf0a5..9af7d3a584f 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -3,8 +3,6 @@ These APIs are the only documented way to interact with the bluetooth integration. """ -from __future__ import annotations - import asyncio from asyncio import Future from collections.abc import Callable, Iterable @@ -207,6 +205,19 @@ def async_clear_address_from_match_history(hass: HomeAssistant, address: str) -> _get_manager(hass).async_clear_address_from_match_history(address) +@hass_callback +def async_clear_advertisement_history(hass: HomeAssistant, address: str) -> None: + """Clear cached advertisement history for a device. + + Causes the next advertisement from this address to be treated as new + data, bypassing the change-detection guard in the Bluetooth manager. + Intended for devices that emit static advertisements as a wake-up + signal, for example, devices that require an active GATT connection + to read sensor data and whose advertisement payload never changes. + """ + _get_manager(hass).async_clear_advertisement_history(address) + + @hass_callback def async_register_scanner( hass: HomeAssistant, diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py index 328707bd722..f027e6fa3c8 100644 --- a/homeassistant/components/bluetooth/config_flow.py +++ b/homeassistant/components/bluetooth/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Bluetooth integration.""" -from __future__ import annotations - import platform from typing import Any, cast diff --git a/homeassistant/components/bluetooth/const.py b/homeassistant/components/bluetooth/const.py index 22c885b4f8b..904b066d95e 100644 --- a/homeassistant/components/bluetooth/const.py +++ b/homeassistant/components/bluetooth/const.py @@ -1,7 +1,5 @@ """Constants for the Bluetooth integration.""" -from __future__ import annotations - from typing import Final from habluetooth import ( # noqa: F401 diff --git a/homeassistant/components/bluetooth/diagnostics.py b/homeassistant/components/bluetooth/diagnostics.py index 1c9c9a56b2e..d9ed7158ac0 100644 --- a/homeassistant/components/bluetooth/diagnostics.py +++ b/homeassistant/components/bluetooth/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for bluetooth.""" -from __future__ import annotations - import platform from typing import Any diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 88f486fcc35..4f923fff8df 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -1,7 +1,5 @@ """The bluetooth integration.""" -from __future__ import annotations - from collections.abc import Callable, Iterable from functools import partial import itertools diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index d187e749b3e..ea2de94e251 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -20,7 +20,7 @@ "bluetooth-adapters==2.1.0", "bluetooth-auto-recovery==1.5.3", "bluetooth-data-tools==1.28.4", - "dbus-fast==3.1.2", - "habluetooth==5.11.1" + "dbus-fast==4.0.4", + "habluetooth==6.1.0" ] } diff --git a/homeassistant/components/bluetooth/match.py b/homeassistant/components/bluetooth/match.py index c755f9dcd1f..2c8c19696cd 100644 --- a/homeassistant/components/bluetooth/match.py +++ b/homeassistant/components/bluetooth/match.py @@ -1,7 +1,5 @@ """The bluetooth integration matchers.""" -from __future__ import annotations - from collections import defaultdict from dataclasses import dataclass from fnmatch import translate diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index deab0043097..d8b51df9ec7 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -1,7 +1,5 @@ """Models for bluetooth.""" -from __future__ import annotations - from collections.abc import Callable from enum import Enum diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index ccff85e5027..b42aae19e48 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -1,7 +1,5 @@ """Passive update coordinator for the Bluetooth integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 37ba413f30f..bf4129ee594 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -1,7 +1,5 @@ """Passive update processors for the Bluetooth integration.""" -from __future__ import annotations - import dataclasses from datetime import timedelta from functools import cache diff --git a/homeassistant/components/bluetooth/storage.py b/homeassistant/components/bluetooth/storage.py index 3222eaef2c5..a425e74ba90 100644 --- a/homeassistant/components/bluetooth/storage.py +++ b/homeassistant/components/bluetooth/storage.py @@ -1,7 +1,5 @@ """Storage for remote scanners.""" -from __future__ import annotations - from habluetooth import ( DiscoveredDeviceAdvertisementData, DiscoveredDeviceAdvertisementDataDict, diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 880824aeccf..aa4f967a150 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -1,7 +1,5 @@ """Update coordinator for the Bluetooth integration.""" -from __future__ import annotations - from abc import ABC, abstractmethod import logging diff --git a/homeassistant/components/bluetooth/util.py b/homeassistant/components/bluetooth/util.py index 738a61b6f33..bb384383134 100644 --- a/homeassistant/components/bluetooth/util.py +++ b/homeassistant/components/bluetooth/util.py @@ -1,7 +1,5 @@ """The bluetooth integration utilities.""" -from __future__ import annotations - from bluetooth_adapters import ( ADAPTER_ADDRESS, ADAPTER_MANUFACTURER, diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py index 042fe3fe24b..82d1fb552e4 100644 --- a/homeassistant/components/bluetooth/websocket_api.py +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -1,7 +1,5 @@ """The bluetooth integration websocket apis.""" -from __future__ import annotations - from collections.abc import Callable, Iterable from functools import lru_cache, partial import time diff --git a/homeassistant/components/bluetooth_adapters/__init__.py b/homeassistant/components/bluetooth_adapters/__init__.py index 90593bf1018..f09aee923db 100644 --- a/homeassistant/components/bluetooth_adapters/__init__.py +++ b/homeassistant/components/bluetooth_adapters/__init__.py @@ -1,7 +1,5 @@ """The Bluetooth Adapters 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/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py index 25a1aa60a1d..5f26eca9f71 100644 --- a/homeassistant/components/bluetooth_le_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py @@ -1,7 +1,5 @@ """Tracking for bluetooth low energy devices.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging from uuid import UUID diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py new file mode 100644 index 00000000000..f6a6c0e888e --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -0,0 +1,39 @@ +"""The BMW Connected Drive integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +DOMAIN = "bmw_connected_drive" + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up BMW Connected Drive from a config entry.""" + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/bmw_connected_drive", + "custom_component_url": "https://github.com/kvanbiesen/bmw-cardata-ha", + }, + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return True + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Remove a config entry.""" + if not hass.config_entries.async_loaded_entries(DOMAIN): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + # Remove any remaining disabled or ignored entries + for _entry in hass.config_entries.async_entries(DOMAIN): + hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id)) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py new file mode 100644 index 00000000000..7295864c29c --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -0,0 +1,9 @@ +"""The BMW Connected Drive integration config flow.""" + +from homeassistant.config_entries import ConfigFlow + +from . import DOMAIN + + +class BMWConnectedDriveConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for BMW Connected Drive.""" diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json new file mode 100644 index 00000000000..b1c3cc9769b --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "bmw_connected_drive", + "name": "BMW Connected Drive", + "codeowners": [], + "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", + "integration_type": "system", + "iot_class": "cloud_polling", + "quality_scale": "legacy", + "requirements": [] +} diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json new file mode 100644 index 00000000000..7ff1b1eb99c --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "integration_removed": { + "description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).", + "title": "The BMW Connected Drive integration has been removed" + } + } +} diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 9cea0251b41..4c8a57b2c64 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -1,7 +1,5 @@ """Support for bond buttons.""" -from __future__ import annotations - from dataclasses import dataclass from bond_async import Action @@ -260,6 +258,14 @@ BUTTONS: tuple[BondButtonEntityDescription, ...] = ( ), ) +PRESET_BUTTON = BondButtonEntityDescription( + key=Action.PRESET, + name="Preset", + translation_key="preset", + mutually_exclusive=None, + argument=None, +) + async def async_setup_entry( hass: HomeAssistant, @@ -285,6 +291,8 @@ async def async_setup_entry( # we only add the stop action button if we add actions # since its not so useful if there are no actions to stop device_entities.append(BondButtonEntity(data, device, STOP_BUTTON)) + if device.has_action(PRESET_BUTTON.key): + device_entities.append(BondButtonEntity(data, device, PRESET_BUTTON)) entities.extend(device_entities) async_add_entities(entities) diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 9fcfbd342d8..f7c0d72354f 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Bond integration.""" -from __future__ import annotations - import contextlib from http import HTTPStatus import logging diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index d2a78819fae..92c62413a6b 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -1,7 +1,5 @@ """Support for Bond covers.""" -from __future__ import annotations - from typing import Any from bond_async import Action, DeviceType diff --git a/homeassistant/components/bond/diagnostics.py b/homeassistant/components/bond/diagnostics.py index 94361097362..5828b0f42cd 100644 --- a/homeassistant/components/bond/diagnostics.py +++ b/homeassistant/components/bond/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for bond.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 2ae1df5fd68..3b7159694fe 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -1,7 +1,5 @@ """An abstract class common to all Bond entities.""" -from __future__ import annotations - from abc import abstractmethod from asyncio import Lock from datetime import datetime diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index c1554caa9a4..3391a953d2b 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -1,7 +1,5 @@ """Support for Bond fans.""" -from __future__ import annotations - import logging import math from typing import Any diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 35524357c8b..477ab379c38 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -1,7 +1,5 @@ """Support for Bond lights.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/bond/models.py b/homeassistant/components/bond/models.py index 7564961ee78..d1b8cc02e70 100644 --- a/homeassistant/components/bond/models.py +++ b/homeassistant/components/bond/models.py @@ -1,7 +1,5 @@ """The bond integration models.""" -from __future__ import annotations - from dataclasses import dataclass from bond_async import BPUPSubscriptions diff --git a/homeassistant/components/bond/services.py b/homeassistant/components/bond/services.py index 7974f6afde5..cc76b947ea5 100644 --- a/homeassistant/components/bond/services.py +++ b/homeassistant/components/bond/services.py @@ -1,7 +1,5 @@ """Support for Bond services.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.fan import DOMAIN as FAN_DOMAIN diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index 9274e4724ff..1ce0af45d1c 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -1,7 +1,5 @@ """Support for Bond generic devices.""" -from __future__ import annotations - from typing import Any from aiohttp.client_exceptions import ClientResponseError diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 0a1067de709..c5af2302413 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -1,7 +1,5 @@ """Reusable utilities for the Bond component.""" -from __future__ import annotations - import logging from typing import Any, cast diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py index f25dedb20bd..31b1fd991ac 100644 --- a/homeassistant/components/bosch_alarm/__init__.py +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -1,7 +1,5 @@ """The Bosch Alarm integration.""" -from __future__ import annotations - from ssl import SSLError from bosch_alarm_mode2 import Panel diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py index b502ee32fca..a5e549dd396 100644 --- a/homeassistant/components/bosch_alarm/alarm_control_panel.py +++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Bosch Alarm Panel.""" -from __future__ import annotations - from bosch_alarm_mode2 import Panel from homeassistant.components.alarm_control_panel import ( diff --git a/homeassistant/components/bosch_alarm/binary_sensor.py b/homeassistant/components/bosch_alarm/binary_sensor.py index ced97f04686..c35804f1271 100644 --- a/homeassistant/components/bosch_alarm/binary_sensor.py +++ b/homeassistant/components/bosch_alarm/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Bosch Alarm Panel binary sensors.""" -from __future__ import annotations - from dataclasses import dataclass from bosch_alarm_mode2 import Panel diff --git a/homeassistant/components/bosch_alarm/config_flow.py b/homeassistant/components/bosch_alarm/config_flow.py index 7b3f549c91d..90ddf9d57f4 100644 --- a/homeassistant/components/bosch_alarm/config_flow.py +++ b/homeassistant/components/bosch_alarm/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Bosch Alarm integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging diff --git a/homeassistant/components/bosch_alarm/entity.py b/homeassistant/components/bosch_alarm/entity.py index 6fe624e8901..f472ca53c72 100644 --- a/homeassistant/components/bosch_alarm/entity.py +++ b/homeassistant/components/bosch_alarm/entity.py @@ -1,7 +1,5 @@ """Support for Bosch Alarm Panel History as a sensor.""" -from __future__ import annotations - from bosch_alarm_mode2 import Panel from homeassistant.components.sensor import Entity diff --git a/homeassistant/components/bosch_alarm/sensor.py b/homeassistant/components/bosch_alarm/sensor.py index 479aaa03049..c2d964d283f 100644 --- a/homeassistant/components/bosch_alarm/sensor.py +++ b/homeassistant/components/bosch_alarm/sensor.py @@ -1,7 +1,5 @@ """Support for Bosch Alarm Panel History as a sensor.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/bosch_alarm/services.py b/homeassistant/components/bosch_alarm/services.py index 1907d4ad45e..b0f917e9859 100644 --- a/homeassistant/components/bosch_alarm/services.py +++ b/homeassistant/components/bosch_alarm/services.py @@ -1,7 +1,5 @@ """Services for the bosch_alarm integration.""" -from __future__ import annotations - import asyncio import datetime as dt from typing import Any diff --git a/homeassistant/components/bosch_alarm/switch.py b/homeassistant/components/bosch_alarm/switch.py index 9d6e48d591d..73d0fccc235 100644 --- a/homeassistant/components/bosch_alarm/switch.py +++ b/homeassistant/components/bosch_alarm/switch.py @@ -1,7 +1,5 @@ """Support for Bosch Alarm Panel outputs and doors as switches.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py index e7818a1007a..e856311d10f 100644 --- a/homeassistant/components/bosch_shc/binary_sensor.py +++ b/homeassistant/components/bosch_shc/binary_sensor.py @@ -1,7 +1,5 @@ """Platform for binarysensor integration.""" -from __future__ import annotations - from boschshcpy import SHCBatteryDevice, SHCShutterContact from boschshcpy.device import SHCDevice diff --git a/homeassistant/components/bosch_shc/config_flow.py b/homeassistant/components/bosch_shc/config_flow.py index c234000674d..2fd46667cab 100644 --- a/homeassistant/components/bosch_shc/config_flow.py +++ b/homeassistant/components/bosch_shc/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Bosch Smart Home Controller integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from os import makedirs diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py index e0e2963c340..f86cc885655 100644 --- a/homeassistant/components/bosch_shc/entity.py +++ b/homeassistant/components/bosch_shc/entity.py @@ -1,7 +1,5 @@ """Bosch Smart Home Controller base entity.""" -from __future__ import annotations - from boschshcpy import SHCDevice, SHCIntrusionSystem from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py index 885908804c0..30969dd6aec 100644 --- a/homeassistant/components/bosch_shc/sensor.py +++ b/homeassistant/components/bosch_shc/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/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py index bf1d5d39ee5..89ab26a86f1 100644 --- a/homeassistant/components/bosch_shc/switch.py +++ b/homeassistant/components/bosch_shc/switch.py @@ -1,7 +1,5 @@ """Platform for switch integration.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/brands/__init__.py b/homeassistant/components/brands/__init__.py index 0cfe254904f..3e51669ac87 100644 --- a/homeassistant/components/brands/__init__.py +++ b/homeassistant/components/brands/__init__.py @@ -1,7 +1,5 @@ """The Brands integration.""" -from __future__ import annotations - from collections import deque from http import HTTPStatus import logging @@ -52,7 +50,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Rotate the access token.""" access_tokens.append(hex(_RND.getrandbits(256))[2:]) - async_track_time_interval(hass, _rotate_token, TOKEN_CHANGE_INTERVAL) + async_track_time_interval( + hass, _rotate_token, TOKEN_CHANGE_INTERVAL, cancel_on_shutdown=True + ) hass.http.register_view(BrandsIntegrationView(hass)) hass.http.register_view(BrandsHardwareView(hass)) diff --git a/homeassistant/components/brands/const.py b/homeassistant/components/brands/const.py index fd2c9672a9e..e9321fb09b6 100644 --- a/homeassistant/components/brands/const.py +++ b/homeassistant/components/brands/const.py @@ -1,7 +1,5 @@ """Constants for the Brands integration.""" -from __future__ import annotations - from datetime import timedelta import re from typing import Final diff --git a/homeassistant/components/braviatv/__init__.py b/homeassistant/components/braviatv/__init__.py index 1c183b397d8..477af6e3f49 100644 --- a/homeassistant/components/braviatv/__init__.py +++ b/homeassistant/components/braviatv/__init__.py @@ -1,15 +1,15 @@ """The Bravia TV integration.""" -from __future__ import annotations - from typing import Final from aiohttp import CookieJar from pybravia import BraviaClient +from homeassistant.components import ssdp from homeassistant.const import CONF_HOST, CONF_MAC, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from .const import CONF_USE_SSL from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator @@ -46,6 +46,19 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + async def async_ssdp_callback( + discovery_info: SsdpServiceInfo, change: ssdp.SsdpChange + ) -> None: + await coordinator.async_request_refresh() + + config_entry.async_on_unload( + await ssdp.async_register_callback( + hass, + async_ssdp_callback, + {"nt": "urn:schemas-upnp-org:device:MediaRenderer:1", "_host": host}, + ) + ) + return True diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py index a1ee159290a..48c86335bb3 100644 --- a/homeassistant/components/braviatv/button.py +++ b/homeassistant/components/braviatv/button.py @@ -1,7 +1,5 @@ """Button support for Bravia TV.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index 01ecddad1d4..54a1b85ec71 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure the Bravia TV integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast from urllib.parse import urlparse diff --git a/homeassistant/components/braviatv/const.py b/homeassistant/components/braviatv/const.py index dc9d452dbbc..164b83cb5fe 100644 --- a/homeassistant/components/braviatv/const.py +++ b/homeassistant/components/braviatv/const.py @@ -1,7 +1,5 @@ """Constants for Bravia TV integration.""" -from __future__ import annotations - from enum import StrEnum from typing import Final diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index f3d5db90e71..903c4b922cd 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -1,7 +1,5 @@ """Update coordinator for Bravia TV integration.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable, Coroutine, Iterable from datetime import datetime, timedelta from functools import wraps @@ -173,6 +171,9 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): power_status = await self.client.get_power_status() self.is_on = power_status == "active" self.skipped_updates = 0 + self.update_interval = ( + timedelta(seconds=120) if power_status == "standby" else SCAN_INTERVAL + ) if not self.system_info: self.system_info = await self.client.get_system_info() diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index c4226190ad8..1884d265d37 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -1,7 +1,5 @@ """Media player support for Bravia TV integration.""" -from __future__ import annotations - from datetime import datetime from typing import Any diff --git a/homeassistant/components/braviatv/remote.py b/homeassistant/components/braviatv/remote.py index 40f552c9258..122e2953887 100644 --- a/homeassistant/components/braviatv/remote.py +++ b/homeassistant/components/braviatv/remote.py @@ -1,7 +1,5 @@ """Remote control support for Bravia TV.""" -from __future__ import annotations - from collections.abc import Iterable from typing import Any diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index 943b4863aac..fb600ce107b 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -1,7 +1,5 @@ """The Bring! integration.""" -from __future__ import annotations - import logging from bring_api import Bring diff --git a/homeassistant/components/bring/config_flow.py b/homeassistant/components/bring/config_flow.py index 9e5f4da8356..256ff006cdf 100644 --- a/homeassistant/components/bring/config_flow.py +++ b/homeassistant/components/bring/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Bring! integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/bring/coordinator.py b/homeassistant/components/bring/coordinator.py index e03acca5bb5..13384a3a476 100644 --- a/homeassistant/components/bring/coordinator.py +++ b/homeassistant/components/bring/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Bring! integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py index 2f5a0cae504..146f3ea7280 100644 --- a/homeassistant/components/bring/diagnostics.py +++ b/homeassistant/components/bring/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Bring.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/homeassistant/components/bring/entity.py b/homeassistant/components/bring/entity.py index 1bb49afeb5d..b3ef8256463 100644 --- a/homeassistant/components/bring/entity.py +++ b/homeassistant/components/bring/entity.py @@ -1,7 +1,5 @@ """Base entity for the Bring! integration.""" -from __future__ import annotations - from bring_api import BringList from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py index 9cc41af10f7..3f9a54707a6 100644 --- a/homeassistant/components/bring/event.py +++ b/homeassistant/components/bring/event.py @@ -1,7 +1,5 @@ """Event platform for Bring integration.""" -from __future__ import annotations - from dataclasses import asdict from datetime import datetime diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json index b2177acb52f..b8ab566aba7 100644 --- a/homeassistant/components/bring/manifest.json +++ b/homeassistant/components/bring/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["bring_api"], "quality_scale": "platinum", - "requirements": ["bring-api==1.1.1"] + "requirements": ["bring-api==1.1.2"] } diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py index 6a22e35ab32..986104598b6 100644 --- a/homeassistant/components/bring/sensor.py +++ b/homeassistant/components/bring/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for the Bring! integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py index 00d2b07e582..67e8aaafd91 100644 --- a/homeassistant/components/bring/todo.py +++ b/homeassistant/components/bring/todo.py @@ -1,7 +1,5 @@ """Todo platform for the Bring! integration.""" -from __future__ import annotations - from itertools import chain from typing import TYPE_CHECKING import uuid diff --git a/homeassistant/components/bring/util.py b/homeassistant/components/bring/util.py index 9a075f7bb89..f8fabffee70 100644 --- a/homeassistant/components/bring/util.py +++ b/homeassistant/components/bring/util.py @@ -1,7 +1,5 @@ """Utility functions for Bring.""" -from __future__ import annotations - from bring_api import BringUserSettingsResponse from .coordinator import BringData diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index 8dd6cee82cb..b58e6605048 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -1,6 +1,5 @@ """The Broadlink integration.""" - -from __future__ import annotations +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from dataclasses import dataclass, field diff --git a/homeassistant/components/broadlink/climate.py b/homeassistant/components/broadlink/climate.py index 5be04c24f0d..78af781d503 100644 --- a/homeassistant/components/broadlink/climate.py +++ b/homeassistant/components/broadlink/climate.py @@ -34,6 +34,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Broadlink climate entities.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data device = hass.data[DOMAIN].devices[config_entry.entry_id] if device.api.type in DOMAINS_AND_TYPES[Platform.CLIMATE]: diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py index 602a3693b7b..d866e701cce 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -6,7 +6,9 @@ DOMAIN = "broadlink" DOMAINS_AND_TYPES = { Platform.CLIMATE: {"HYS"}, + Platform.INFRARED: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}, Platform.LIGHT: {"LB1", "LB2"}, + Platform.RADIO_FREQUENCY: {"RM4PRO", "RMPRO"}, Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"}, Platform.SELECT: {"HYS"}, Platform.SENSOR: { diff --git a/homeassistant/components/broadlink/device.py b/homeassistant/components/broadlink/device.py index e7f4f792ab2..c61c2c26380 100644 --- a/homeassistant/components/broadlink/device.py +++ b/homeassistant/components/broadlink/device.py @@ -133,6 +133,8 @@ class BroadlinkDevice[_ApiT: blk.Device = blk.Device]: await coordinator.async_config_entry_first_refresh() self.update_manager = update_manager + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data self.hass.data[DOMAIN].devices[config.entry_id] = self self.reset_jobs.append(config.add_update_listener(self.async_update)) diff --git a/homeassistant/components/broadlink/infrared.py b/homeassistant/components/broadlink/infrared.py new file mode 100644 index 00000000000..32ec791b857 --- /dev/null +++ b/homeassistant/components/broadlink/infrared.py @@ -0,0 +1,67 @@ +"""Infrared platform for Broadlink remotes.""" + +from typing import TYPE_CHECKING + +from broadlink.exceptions import BroadlinkException +from broadlink.remote import pulses_to_data as _bl_pulses_to_data + +from homeassistant.components.infrared import InfraredCommand, InfraredEntity +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 .entity import BroadlinkEntity + +if TYPE_CHECKING: + from .device import BroadlinkDevice + +PARALLEL_UPDATES = 1 + + +def _timings_to_broadlink_packet(timings: list[int]) -> bytes: + """Convert signed microsecond timings to a Broadlink IR packet. + + Positive values are pulse (high) durations; negative values are space + (low) durations. The Broadlink library's encoder expects absolute + durations. + """ + pulses = [abs(t) for t in timings] + return _bl_pulses_to_data(pulses) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Broadlink infrared entity.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data + device = hass.data[DOMAIN].devices[config_entry.entry_id] + async_add_entities([BroadlinkInfraredEntity(device)]) + + +class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEntity): + """Broadlink infrared transmitter entity.""" + + _attr_has_entity_name = True + _attr_translation_key = "infrared_emitter" + + def __init__(self, device: BroadlinkDevice) -> None: + """Initialize the entity.""" + super().__init__(device) + self._attr_unique_id = f"{device.unique_id}-emitter" + + async def async_send_command(self, command: InfraredCommand) -> None: + """Send an IR command via the Broadlink device.""" + packet = _timings_to_broadlink_packet(command.get_raw_timings()) + try: + await self._device.async_request(self._device.api.send_data, packet) + except (BroadlinkException, OSError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_command_failed", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/broadlink/light.py b/homeassistant/components/broadlink/light.py index 64698e57249..2df3cab4366 100644 --- a/homeassistant/components/broadlink/light.py +++ b/homeassistant/components/broadlink/light.py @@ -32,6 +32,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Broadlink light.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data device = hass.data[DOMAIN].devices[config_entry.entry_id] lights = [] diff --git a/homeassistant/components/broadlink/radio_frequency.py b/homeassistant/components/broadlink/radio_frequency.py new file mode 100644 index 00000000000..ec8673aa98a --- /dev/null +++ b/homeassistant/components/broadlink/radio_frequency.py @@ -0,0 +1,130 @@ +"""Radio Frequency platform for Broadlink.""" + +import logging + +from broadlink.exceptions import BroadlinkException +from rf_protocols import RadioFrequencyCommand + +from homeassistant.components.radio_frequency import RadioFrequencyTransmitterEntity +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 .device import BroadlinkDevice +from .entity import BroadlinkEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + +_TICK_US = 32.84 + +_RF_433_TYPE_BYTE = 0xB2 +_RF_315_TYPE_BYTE = 0xB4 + +_RF_433_RANGE = (433_050_000, 434_790_000) +_RF_315_RANGE = (314_950_000, 315_250_000) + +SUPPORTED_FREQUENCY_RANGES: list[tuple[int, int]] = [_RF_433_RANGE, _RF_315_RANGE] + + +def _type_byte_for_frequency(frequency: int) -> int: + """Return the Broadlink RF type byte for a given carrier frequency.""" + if _RF_433_RANGE[0] <= frequency <= _RF_433_RANGE[1]: + return _RF_433_TYPE_BYTE + if _RF_315_RANGE[0] <= frequency <= _RF_315_RANGE[1]: + return _RF_315_TYPE_BYTE + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="frequency_not_supported", + translation_placeholders={"frequency": f"{frequency / 1_000_000:g}"}, + ) + + +def encode_rf_packet( + *, + type_byte: int, + repeat_count: int, + timings_us: list[int], +) -> bytes: + """Encode raw OOK timings as a Broadlink RF pulse-length packet. + + The layout is:: + + byte 0 type byte (0xB2 for 433 MHz, 0xB4 for 315 MHz) + byte 1 repeat count (additional transmissions after the first) + bytes 2..3 payload length (little-endian), counted from byte 4 + bytes 4..N-1 pulses: 1 byte when ticks < 256, otherwise + 0x00 followed by a 2-byte big-endian tick count + + Each pulse is expressed as multiples of 32.84 µs ticks, which is the + timing resolution of the Broadlink RF front-end. + """ + buf = bytearray([type_byte, repeat_count, 0, 0]) + for duration in timings_us: + ticks = round(abs(duration) / _TICK_US) + div, mod = divmod(ticks, 256) + if div: + buf.append(0x00) + buf.append(div) + buf.append(mod) + payload_len = len(buf) - 4 + buf[2] = payload_len & 0xFF + buf[3] = (payload_len >> 8) & 0xFF + return bytes(buf) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up a Broadlink radio frequency transmitter.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data + device: BroadlinkDevice = hass.data[DOMAIN].devices[config_entry.entry_id] + async_add_entities([BroadlinkRadioFrequency(device)]) + + +class BroadlinkRadioFrequency(BroadlinkEntity, RadioFrequencyTransmitterEntity): + """Representation of a Broadlink RF transmitter.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, device: BroadlinkDevice) -> None: + """Initialize the entity.""" + super().__init__(device) + self._attr_unique_id = device.unique_id + + @property + def supported_frequency_ranges(self) -> list[tuple[int, int]]: + """Return the Broadlink-supported narrow RF bands.""" + return SUPPORTED_FREQUENCY_RANGES + + async def async_send_command(self, command: RadioFrequencyCommand) -> None: + """Encode an OOK command and transmit it via the Broadlink device.""" + type_byte = _type_byte_for_frequency(command.frequency) + packet = encode_rf_packet( + type_byte=type_byte, + repeat_count=command.repeat_count, + timings_us=command.get_raw_timings(), + ) + _LOGGER.debug( + "Transmitting RF packet: %d bytes on %d Hz (repeat=%d)", + len(packet), + command.frequency, + command.repeat_count, + ) + + device = self._device + try: + await device.async_request(device.api.send_data, packet) + except (BroadlinkException, OSError) as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="transmit_failed", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index 4cd2cc9e06c..363a344a9d2 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -95,6 +95,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Broadlink remote.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data device = hass.data[DOMAIN].devices[config_entry.entry_id] remote = BroadlinkRemote( device, diff --git a/homeassistant/components/broadlink/select.py b/homeassistant/components/broadlink/select.py index 661fc62600d..ec43dd5a519 100644 --- a/homeassistant/components/broadlink/select.py +++ b/homeassistant/components/broadlink/select.py @@ -1,7 +1,5 @@ """Support for Broadlink selects.""" -from __future__ import annotations - from typing import Any from homeassistant.components.select import SelectEntity @@ -31,6 +29,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Broadlink select.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data device = hass.data[DOMAIN].devices[config_entry.entry_id] async_add_entities([BroadlinkDayOfWeek(device)]) diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 5323a08d227..99a7482e556 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -1,7 +1,5 @@ """Support for Broadlink sensors.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -108,6 +106,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Broadlink sensor.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data device = hass.data[DOMAIN].devices[config_entry.entry_id] sensor_data = device.update_manager.coordinator.data sensors = [ diff --git a/homeassistant/components/broadlink/strings.json b/homeassistant/components/broadlink/strings.json index a019f350ec0..c291e7a77a0 100644 --- a/homeassistant/components/broadlink/strings.json +++ b/homeassistant/components/broadlink/strings.json @@ -49,6 +49,11 @@ } }, "entity": { + "infrared": { + "infrared_emitter": { + "name": "IR emitter" + } + }, "select": { "day_of_week": { "name": "Day of week", @@ -77,5 +82,16 @@ "name": "Total consumption" } } + }, + "exceptions": { + "frequency_not_supported": { + "message": "Broadlink devices cannot transmit on {frequency} MHz" + }, + "send_command_failed": { + "message": "Failed to send IR command: {error}" + }, + "transmit_failed": { + "message": "Failed to transmit RF command: {error}" + } } } diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index d6869ac4c9c..369dcf8e3f0 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -1,6 +1,5 @@ """Support for Broadlink switches.""" - -from __future__ import annotations +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from abc import ABC, abstractmethod import logging diff --git a/homeassistant/components/broadlink/time.py b/homeassistant/components/broadlink/time.py index 4687df6b8b6..52b3ae82d16 100644 --- a/homeassistant/components/broadlink/time.py +++ b/homeassistant/components/broadlink/time.py @@ -1,7 +1,5 @@ """Support for Broadlink device time.""" -from __future__ import annotations - from datetime import time from typing import Any @@ -22,6 +20,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Broadlink time.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data device = hass.data[DOMAIN].devices[config_entry.entry_id] async_add_entities([BroadlinkTime(device)]) diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index 8fdbb5054a8..1f9da096858 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -1,7 +1,5 @@ """Support for fetching data from Broadlink devices.""" -from __future__ import annotations - from abc import ABC, abstractmethod from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index f969ee7b17a..e4ea48efee0 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -1,7 +1,5 @@ """The Brother component.""" -from __future__ import annotations - import logging from brother import Brother, SnmpError diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index 057e23b313c..5ce710f80a9 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Brother Printer.""" -from __future__ import annotations - from typing import Any from brother import Brother, SnmpError, UnsupportedModelError diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index 49b229ee164..8cc4065ce21 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -1,7 +1,5 @@ """Constants for Brother integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Final diff --git a/homeassistant/components/brother/diagnostics.py b/homeassistant/components/brother/diagnostics.py index 33b2e8297e4..664da8aa705 100644 --- a/homeassistant/components/brother/diagnostics.py +++ b/homeassistant/components/brother/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Brother.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 1f95fefc66e..af519876eb8 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], "quality_scale": "platinum", - "requirements": ["brother==6.0.0"], + "requirements": ["brother==6.1.0"], "zeroconf": [ { "name": "brother*", diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 4f1a10c2621..faeaf646f53 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -1,7 +1,5 @@ """Support for the Brother service.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -293,9 +291,8 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = ( ), BrotherSensorEntityDescription( key="uptime", - translation_key="last_restart", entity_registry_enabled_default=False, - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, value=lambda data: data.uptime, ), diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json index f52875018c1..428edc25cb5 100644 --- a/homeassistant/components/brother/strings.json +++ b/homeassistant/components/brother/strings.json @@ -151,9 +151,6 @@ "laser_remaining_life": { "name": "Laser remaining lifetime" }, - "last_restart": { - "name": "Last restart" - }, "magenta_drum_page_counter": { "name": "Magenta drum page counter", "unit_of_measurement": "[%key:component::brother::entity::sensor::page_counter::unit_of_measurement%]" diff --git a/homeassistant/components/brottsplatskartan/__init__.py b/homeassistant/components/brottsplatskartan/__init__.py index 486bee5bcd5..36f7c4ca0ae 100644 --- a/homeassistant/components/brottsplatskartan/__init__.py +++ b/homeassistant/components/brottsplatskartan/__init__.py @@ -1,7 +1,5 @@ """The brottsplatskartan component.""" -from __future__ import annotations - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/brottsplatskartan/config_flow.py b/homeassistant/components/brottsplatskartan/config_flow.py index ef35b3bd4f1..ee6eb5434b0 100644 --- a/homeassistant/components/brottsplatskartan/config_flow.py +++ b/homeassistant/components/brottsplatskartan/config_flow.py @@ -1,7 +1,5 @@ """Adds config flow for Brottsplatskartan integration.""" -from __future__ import annotations - from typing import Any import uuid diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index 60f9a8163de..ce09b266491 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Brottsplatskartan information.""" -from __future__ import annotations - from collections import defaultdict from datetime import timedelta from typing import Literal diff --git a/homeassistant/components/brunt/__init__.py b/homeassistant/components/brunt/__init__.py index c488c813b3b..00947a0ec3f 100644 --- a/homeassistant/components/brunt/__init__.py +++ b/homeassistant/components/brunt/__init__.py @@ -1,7 +1,5 @@ """The brunt component.""" -from __future__ import annotations - from homeassistant.core import HomeAssistant from .const import PLATFORMS diff --git a/homeassistant/components/brunt/config_flow.py b/homeassistant/components/brunt/config_flow.py index 3baea9b98cc..e7fc87ea365 100644 --- a/homeassistant/components/brunt/config_flow.py +++ b/homeassistant/components/brunt/config_flow.py @@ -1,7 +1,5 @@ """Config flow for brunt integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/brunt/coordinator.py b/homeassistant/components/brunt/coordinator.py index b07ec2c0c88..947676630a2 100644 --- a/homeassistant/components/brunt/coordinator.py +++ b/homeassistant/components/brunt/coordinator.py @@ -1,7 +1,5 @@ """The brunt component.""" -from __future__ import annotations - from asyncio import timeout import logging diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 95931d3449e..b02a8a96c8b 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -1,7 +1,5 @@ """Support for Brunt Blind Engine covers.""" -from __future__ import annotations - from typing import Any from aiohttp.client_exceptions import ClientResponseError diff --git a/homeassistant/components/bryant_evolution/__init__.py b/homeassistant/components/bryant_evolution/__init__.py index 6ff58ad5df5..51e7a9eb56f 100644 --- a/homeassistant/components/bryant_evolution/__init__.py +++ b/homeassistant/components/bryant_evolution/__init__.py @@ -1,7 +1,5 @@ """The Bryant Evolution integration.""" -from __future__ import annotations - import logging from evolutionhttp import BryantEvolutionLocalClient diff --git a/homeassistant/components/bryant_evolution/config_flow.py b/homeassistant/components/bryant_evolution/config_flow.py index 2e5a094948d..1730e55838d 100644 --- a/homeassistant/components/bryant_evolution/config_flow.py +++ b/homeassistant/components/bryant_evolution/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Bryant Evolution integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 0520cb8039e..1e5b641a357 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -13,6 +13,7 @@ from bsblan import ( Info, StaticState, ) +from yarl import URL from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -28,11 +29,16 @@ from homeassistant.exceptions import ( ConfigEntryError, ConfigEntryNotReady, ) -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.typing import ConfigType -from .const import CONF_PASSKEY, DOMAIN, LOGGER +from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER from .coordinator import BSBLanFastCoordinator, BSBLanSlowCoordinator from .services import async_setup_services @@ -52,7 +58,35 @@ class BSBLanData: client: BSBLAN device: Device info: Info - static: StaticState | None + static: dict[int, StaticState | None] + available_circuits: list[int] + + +def get_bsblan_device_info( + device: Device, info: Info, host: str, port: int +) -> DeviceInfo: + """Build DeviceInfo for the main BSB-LAN controller device.""" + return DeviceInfo( + identifiers={(DOMAIN, device.MAC)}, + connections={(CONNECTION_NETWORK_MAC, format_mac(device.MAC))}, + name=device.name, + manufacturer="BSBLAN Inc.", + model=( + info.device_identification.value + if info.device_identification and info.device_identification.value + else None + ), + model_id=( + f"{info.controller_family.value}_{info.controller_variant.value}" + if info.controller_family + and info.controller_variant + and info.controller_family.value + and info.controller_variant.value + else None + ), + sw_version=device.version, + configuration_url=str(URL.build(scheme="http", host=host, port=port)), + ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -75,13 +109,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo # create BSBLAN client session = async_get_clientsession(hass) - bsblan = BSBLAN(config, session) + bsblan = BSBLAN(config=config, session=session) try: # Initialize the client first - this sets up internal caches and validates # the connection by fetching firmware version await bsblan.initialize() + # Read available heating circuits from config entry data + # (populated by config flow or migration) + circuits: list[int] = entry.data[CONF_HEATING_CIRCUITS] + # Fetch required device metadata in parallel for faster startup device, info = await asyncio.gather( bsblan.device(), @@ -110,18 +148,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo translation_key="setup_general_error", ) from err - try: - static = await bsblan.static_values() - except (BSBLANError, TimeoutError) as err: - LOGGER.debug( - "Static values not available for %s: %s", - entry.data[CONF_HOST], - err, - ) - static = None + # Fetch static values per configured circuit. + # BSB-LAN is a serial bus — it processes one parameter at a time, + # so concurrent requests offer no speed benefit over sequential. + # Static values are optional — some devices may not support them. + static_per_circuit: dict[int, StaticState | None] = {} + for circuit in circuits: + try: + static_per_circuit[circuit] = await bsblan.static_values(circuit=circuit) + except (BSBLANError, TimeoutError) as err: + LOGGER.debug( + "Static values not available for %s circuit %d: %s", + entry.data[CONF_HOST], + circuit, + err, + ) + static_per_circuit[circuit] = None # Create coordinators with the already-initialized client - fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan) + fast_coordinator = BSBLanFastCoordinator(hass, entry, bsblan, circuits) slow_coordinator = BSBLanSlowCoordinator(hass, entry, bsblan) # Perform first refresh of fast coordinator (required for entities) @@ -137,7 +182,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo slow_coordinator=slow_coordinator, device=device, info=info, - static=static, + static=static_per_circuit, + available_circuits=circuits, + ) + + # Register main device before forwarding platforms, so sub-devices + # (heating circuits, water heater) can reference it via via_device + device_registry = dr.async_get(hass) + port = entry.data.get(CONF_PORT, DEFAULT_PORT) + main_device_info = get_bsblan_device_info(device, info, entry.data[CONF_HOST], port) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers=main_device_info["identifiers"], + connections=main_device_info["connections"], + name=main_device_info["name"], + manufacturer=main_device_info["manufacturer"], + model=main_device_info.get("model"), + model_id=main_device_info.get("model_id"), + sw_version=main_device_info.get("sw_version"), + configuration_url=main_device_info.get("configuration_url"), ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -148,3 +211,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool: """Unload BSBLAN config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bool: + """Migrate old config entries to the latest schema.""" + LOGGER.debug( + "Migrating BSB-LAN entry from version %s.%s", + entry.version, + entry.minor_version, + ) + + if entry.version > 1: + # Downgraded from a future version; cannot migrate. + return False + + # 1.1 -> 1.2: Add CONF_HEATING_CIRCUITS. Attempt to discover available + # heating circuits from the device; fall back to [1] (pre-multi-circuit + # default) if the device is unreachable or the endpoint is unsupported. + if entry.version == 1 and entry.minor_version < 2: + circuits: list[int] = [1] + config = BSBLANConfig( + host=entry.data[CONF_HOST], + passkey=entry.data[CONF_PASSKEY], + port=entry.data[CONF_PORT], + username=entry.data.get(CONF_USERNAME), + password=entry.data.get(CONF_PASSWORD), + ) + session = async_get_clientsession(hass) + bsblan = BSBLAN(config=config, session=session) + try: + await bsblan.initialize() + circuits = await bsblan.get_available_circuits() + except (BSBLANError, TimeoutError) as err: + LOGGER.warning( + "Circuit discovery during migration failed for %s (%s); " + "defaulting to single circuit [1]. Use Reconfigure to " + "rediscover additional circuits later", + entry.data[CONF_HOST], + err, + ) + + hass.config_entries.async_update_entry( + entry, + data={**entry.data, CONF_HEATING_CIRCUITS: circuits}, + minor_version=2, + ) + LOGGER.debug( + "Migrated BSB-LAN entry to version %s.%s with circuits %s", + entry.version, + entry.minor_version, + circuits, + ) + + return True diff --git a/homeassistant/components/bsblan/button.py b/homeassistant/components/bsblan/button.py index 9d3261814a2..866ef781d53 100644 --- a/homeassistant/components/bsblan/button.py +++ b/homeassistant/components/bsblan/button.py @@ -1,7 +1,5 @@ """Button platform for BSB-Lan 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/bsblan/climate.py b/homeassistant/components/bsblan/climate.py index 8ae03e0a7a2..32cd3b4c9b2 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -1,10 +1,8 @@ """BSBLAN platform to control a compatible Climate Device.""" -from __future__ import annotations - from typing import Any, Final -from bsblan import BSBLANError, get_hvac_action_category +from bsblan import BSBLANError, State, get_hvac_action_category from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -24,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BSBLanConfigEntry, BSBLanData from .const import ATTR_TARGET_TEMPERATURE, DOMAIN -from .entity import BSBLanEntity +from .entity import BSBLanCircuitEntity PARALLEL_UPDATES = 1 @@ -63,10 +61,12 @@ async def async_setup_entry( ) -> None: """Set up BSBLAN device based on a config entry.""" data = entry.runtime_data - async_add_entities([BSBLANClimate(data)]) + async_add_entities( + BSBLANClimate(data, circuit) for circuit in data.available_circuits + ) -class BSBLANClimate(BSBLanEntity, ClimateEntity): +class BSBLANClimate(BSBLanCircuitEntity, ClimateEntity): """Defines a BSBLAN climate device.""" _attr_name = None @@ -84,37 +84,50 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity): def __init__( self, data: BSBLanData, + circuit: int, ) -> None: """Initialize BSBLAN climate device.""" - super().__init__(data.fast_coordinator, data) - self._attr_unique_id = f"{format_mac(data.device.MAC)}-climate" + super().__init__(data.fast_coordinator, data, circuit) + self._circuit = circuit + mac = format_mac(data.device.MAC) - # Set temperature range if available, otherwise use Home Assistant defaults - if (static := data.static) is not None: + # Backward compatible unique ID: circuit 1 keeps old format + if circuit == 1: + self._attr_unique_id = f"{mac}-climate" + else: + self._attr_unique_id = f"{mac}-climate-{circuit}" + + # Set temperature range from per-circuit static data + if (static := data.static.get(circuit)) is not None: if (min_temp := static.min_temp) is not None and min_temp.value is not None: self._attr_min_temp = min_temp.value if (max_temp := static.max_temp) is not None and max_temp.value is not None: self._attr_max_temp = max_temp.value self._attr_temperature_unit = data.fast_coordinator.client.get_temperature_unit + @property + def _circuit_state(self) -> State: + """Return the state for this circuit.""" + return self.coordinator.data.states[self._circuit] + @property def current_temperature(self) -> float | None: """Return the current temperature.""" - if (current_temp := self.coordinator.data.state.current_temperature) is None: + if (current_temp := self._circuit_state.current_temperature) is None: return None return current_temp.value @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - if (target_temp := self.coordinator.data.state.target_temperature) is None: + if (target_temp := self._circuit_state.target_temperature) is None: return None return target_temp.value @property def _hvac_mode_value(self) -> int | None: """Return the raw hvac_mode value from the coordinator.""" - if (hvac_mode := self.coordinator.data.state.hvac_mode) is None: + if (hvac_mode := self._circuit_state.hvac_mode) is None: return None return hvac_mode.value @@ -128,9 +141,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity): @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac action.""" - if ( - action := self.coordinator.data.state.hvac_action - ) is None or action.value is None: + if (action := self._circuit_state.hvac_action) is None or action.value is None: return None category = get_hvac_action_category(action.value) return HVACAction(category.name.lower()) @@ -170,7 +181,7 @@ class BSBLANClimate(BSBLanEntity, ClimateEntity): data[ATTR_HVAC_MODE] = 1 try: - await self.coordinator.client.thermostat(**data) + await self.coordinator.client.thermostat(**data, circuit=self._circuit) except BSBLANError as err: raise HomeAssistantError( "An error occurred while updating the BSBLAN device", diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index 01024a07e42..c8713be1a38 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -1,7 +1,5 @@ """Config flow for BSB-LAN integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any @@ -15,19 +13,21 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN +from .const import CONF_HEATING_CIRCUITS, CONF_PASSKEY, DEFAULT_PORT, DOMAIN, LOGGER class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a BSBLAN config flow.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize BSBLan flow.""" self.host: str = "" self.port: int = DEFAULT_PORT self.mac: str | None = None + self.circuits: list[int] = [1] self.passkey: str | None = None self.username: str | None = None self.password: str | None = None @@ -77,7 +77,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): # Try to get device info without authentication to minimize discovery popup config = BSBLANConfig(host=self.host, port=self.port) session = async_get_clientsession(self.hass) - bsblan = BSBLAN(config, session) + bsblan = BSBLAN(config=config, session=session) try: device = await bsblan.device() except BSBLANError: @@ -123,6 +123,8 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): ) if not self._auth_required: + # Discover available heating circuits + await self._discover_circuits() return self._async_create_entry() self.passkey = user_input.get(CONF_PASSKEY) @@ -137,6 +139,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): """Validate device connection and create entry.""" try: await self._get_bsblan_info() + await self._discover_circuits() except BSBLANAuthError: if is_discovery: return self.async_show_form( @@ -230,9 +233,12 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): # it gets the unique ID from the device info when it validates credentials self._abort_if_unique_id_mismatch() + # Rediscover circuits in case hardware changed + await self._discover_circuits() + return self.async_update_reload_and_abort( existing_entry, - data_updates=user_input, + data_updates={**user_input, CONF_HEATING_CIRCUITS: self.circuits}, reason="reconfigure_successful", ) @@ -316,13 +322,14 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): def _async_create_entry(self) -> ConfigFlowResult: """Create the config entry.""" return self.async_create_entry( - title=format_mac(self.mac), + title="BSB-LAN", data={ CONF_HOST: self.host, CONF_PORT: self.port, CONF_PASSKEY: self.passkey, CONF_USERNAME: self.username, CONF_PASSWORD: self.password, + CONF_HEATING_CIRCUITS: self.circuits, }, ) @@ -340,7 +347,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): password=self.password, ) session = async_get_clientsession(self.hass) - bsblan = BSBLAN(config, session) + bsblan = BSBLAN(config=config, session=session) device = await bsblan.device() retrieved_mac = device.MAC @@ -362,3 +369,27 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): CONF_PORT: self.port, } ) + + async def _discover_circuits(self) -> None: + """Discover available heating circuits.""" + config = BSBLANConfig( + host=self.host, + passkey=self.passkey, + port=self.port, + username=self.username, + password=self.password, + ) + session = async_get_clientsession(self.hass) + bsblan = BSBLAN(config=config, session=session) + try: + await bsblan.initialize() + self.circuits = await bsblan.get_available_circuits() + except ( + BSBLANError, + TimeoutError, + ): + LOGGER.debug( + "Circuit discovery not available for %s, defaulting to single circuit", + self.host, + ) + self.circuits = [1] diff --git a/homeassistant/components/bsblan/const.py b/homeassistant/components/bsblan/const.py index 8dfdc180089..57299aa4658 100644 --- a/homeassistant/components/bsblan/const.py +++ b/homeassistant/components/bsblan/const.py @@ -1,7 +1,5 @@ """Constants for the BSB-LAN integration.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final @@ -22,5 +20,6 @@ ATTR_INSIDE_TEMPERATURE: Final = "inside_temperature" ATTR_OUTSIDE_TEMPERATURE: Final = "outside_temperature" CONF_PASSKEY: Final = "passkey" +CONF_HEATING_CIRCUITS: Final = "heating_circuits" DEFAULT_PORT: Final = 80 diff --git a/homeassistant/components/bsblan/coordinator.py b/homeassistant/components/bsblan/coordinator.py index e1869d5f772..04209211bea 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the BSB-LAN integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta from typing import TYPE_CHECKING @@ -10,6 +8,7 @@ from bsblan import ( BSBLAN, BSBLANAuthError, BSBLANConnectionError, + BSBLANError, HotWaterConfig, HotWaterSchedule, HotWaterState, @@ -48,9 +47,9 @@ DHW_CONFIG_INCLUDE = ["reduced_setpoint", "nominal_setpoint_max"] class BSBLanFastData: """BSBLan fast-polling data.""" - state: State + states: dict[int, State] sensor: Sensor - dhw: HotWaterState + dhw: HotWaterState | None = None @dataclass @@ -93,6 +92,7 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]): hass: HomeAssistant, config_entry: BSBLanConfigEntry, client: BSBLAN, + circuits: list[int], ) -> None: """Initialize the BSB-LAN fast coordinator.""" super().__init__( @@ -102,16 +102,20 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]): name=f"{DOMAIN}_fast_{config_entry.data[CONF_HOST]}", update_interval=SCAN_INTERVAL_FAST, ) + self.circuits: list[int] = circuits async def _async_update_data(self) -> BSBLanFastData: """Fetch fast-changing data from the BSB-LAN device.""" + states: dict[int, State] = {} try: - # Client is already initialized in async_setup_entry - # Use include filtering to only fetch parameters we actually use - # This reduces response time significantly (~0.2s per parameter) - state = await self.client.state(include=STATE_INCLUDE) + # Use include filtering to only fetch parameters we actually use. + # BSB-LAN is a serial bus — it processes one parameter at a time, + # so concurrent requests offer no speed benefit over sequential. + for circuit in self.circuits: + states[circuit] = await self.client.state( + include=STATE_INCLUDE, circuit=circuit + ) sensor = await self.client.sensor(include=SENSOR_INCLUDE) - dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE) except BSBLANAuthError as err: raise ConfigEntryAuthFailed( @@ -126,8 +130,21 @@ class BSBLanFastCoordinator(BSBLanCoordinator[BSBLanFastData]): translation_placeholders={"host": host}, ) from err + # Fetch DHW state separately - device may not support hot water + dhw: HotWaterState | None = None + try: + dhw = await self.client.hot_water_state(include=DHW_STATE_INCLUDE) + except BSBLANError: + # Preserve last known DHW state if available (entity may depend on it) + if self.data: + dhw = self.data.dhw + LOGGER.debug( + "DHW (Domestic Hot Water) state not available on device at %s", + self.config_entry.data[CONF_HOST], + ) + return BSBLanFastData( - state=state, + states=states, sensor=sensor, dhw=dhw, ) @@ -159,13 +176,6 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]): dhw_config = await self.client.hot_water_config(include=DHW_CONFIG_INCLUDE) dhw_schedule = await self.client.hot_water_schedule() - except AttributeError: - # Device does not support DHW functionality - LOGGER.debug( - "DHW (Domestic Hot Water) not available on device at %s", - self.config_entry.data[CONF_HOST], - ) - return BSBLanSlowData() except (BSBLANConnectionError, BSBLANAuthError) as err: # If config update fails, keep existing data LOGGER.debug( @@ -177,6 +187,13 @@ class BSBLanSlowCoordinator(BSBLanCoordinator[BSBLanSlowData]): return self.data # First fetch failed, return empty data return BSBLanSlowData() + except BSBLANError, AttributeError: + # Device does not support DHW functionality + LOGGER.debug( + "DHW (Domestic Hot Water) not available on device at %s", + self.config_entry.data[CONF_HOST], + ) + return BSBLanSlowData() return BSBLanSlowData( dhw_config=dhw_config, diff --git a/homeassistant/components/bsblan/diagnostics.py b/homeassistant/components/bsblan/diagnostics.py index 55dedead851..7635d3cdf45 100644 --- a/homeassistant/components/bsblan/diagnostics.py +++ b/homeassistant/components/bsblan/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for BSBLan.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant @@ -20,11 +18,20 @@ async def async_get_config_entry_diagnostics( "info": data.info.model_dump(), "device": data.device.model_dump(), "fast_coordinator_data": { - "state": data.fast_coordinator.data.state.model_dump(), + "states": { + str(circuit): state.model_dump() + for circuit, state in data.fast_coordinator.data.states.items() + }, "sensor": data.fast_coordinator.data.sensor.model_dump(), - "dhw": data.fast_coordinator.data.dhw.model_dump(), + "dhw": data.fast_coordinator.data.dhw.model_dump() + if data.fast_coordinator.data.dhw + else None, }, - "static": data.static.model_dump() if data.static is not None else None, + "static": { + str(circuit): static.model_dump() if static is not None else None + for circuit, static in data.static.items() + }, + "available_circuits": data.available_circuits, } # Add DHW config and schedule from slow coordinator if available diff --git a/homeassistant/components/bsblan/entity.py b/homeassistant/components/bsblan/entity.py index e95873ac85d..fdedf01e25a 100644 --- a/homeassistant/components/bsblan/entity.py +++ b/homeassistant/components/bsblan/entity.py @@ -1,16 +1,11 @@ """BSBLan base entity.""" -from __future__ import annotations - -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceInfo, - format_mac, -) +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import BSBLanData -from .const import DOMAIN +from . import BSBLanData, get_bsblan_device_info +from .const import DEFAULT_PORT, DOMAIN from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator @@ -22,29 +17,10 @@ class BSBLanEntityBase[_T: BSBLanCoordinator](CoordinatorEntity[_T]): def __init__(self, coordinator: _T, data: BSBLanData) -> None: """Initialize BSBLan entity with device info.""" super().__init__(coordinator) - host = coordinator.config_entry.data["host"] - mac = data.device.MAC - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, mac)}, - connections={(CONNECTION_NETWORK_MAC, format_mac(mac))}, - name=data.device.name, - manufacturer="BSBLAN Inc.", - model=( - data.info.device_identification.value - if data.info.device_identification - and data.info.device_identification.value - else None - ), - model_id=( - f"{data.info.controller_family.value}_{data.info.controller_variant.value}" - if data.info.controller_family - and data.info.controller_variant - and data.info.controller_family.value - and data.info.controller_variant.value - else None - ), - sw_version=data.device.version, - configuration_url=f"http://{host}", + host = coordinator.config_entry.data[CONF_HOST] + port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT) + self._attr_device_info = get_bsblan_device_info( + data.device, data.info, host, port ) @@ -56,6 +32,32 @@ class BSBLanEntity(BSBLanEntityBase[BSBLanFastCoordinator]): super().__init__(coordinator, data) +class BSBLanCircuitEntity(BSBLanEntity): + """BSBLan entity belonging to a heating circuit sub-device.""" + + def __init__( + self, + coordinator: BSBLanFastCoordinator, + data: BSBLanData, + circuit: int, + ) -> None: + """Initialize BSBLan circuit entity with sub-device info.""" + super().__init__(coordinator, data) + mac = data.device.MAC + host = coordinator.config_entry.data[CONF_HOST] + port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT) + main_info = get_bsblan_device_info(data.device, data.info, host, port) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{mac}-circuit-{circuit}")}, + translation_key="heating_circuit", + translation_placeholders={"circuit": str(circuit)}, + via_device=(DOMAIN, mac), + manufacturer=main_info["manufacturer"], + model=main_info.get("model"), + model_id=main_info.get("model_id"), + ) + + class BSBLanDualCoordinatorEntity(BSBLanEntity): """Entity that listens to both fast and slow coordinators.""" @@ -76,3 +78,28 @@ class BSBLanDualCoordinatorEntity(BSBLanEntity): self.async_on_remove( self.slow_coordinator.async_add_listener(self._handle_coordinator_update) ) + + +class BSBLanWaterHeaterDeviceEntity(BSBLanDualCoordinatorEntity): + """BSBLan entity belonging to the water heater sub-device.""" + + def __init__( + self, + fast_coordinator: BSBLanFastCoordinator, + slow_coordinator: BSBLanSlowCoordinator, + data: BSBLanData, + ) -> None: + """Initialize BSBLan water heater sub-device entity.""" + super().__init__(fast_coordinator, slow_coordinator, data) + mac = data.device.MAC + host = fast_coordinator.config_entry.data[CONF_HOST] + port = fast_coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT) + main_info = get_bsblan_device_info(data.device, data.info, host, port) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{mac}-water-heater")}, + translation_key="water_heater", + via_device=(DOMAIN, mac), + manufacturer=main_info["manufacturer"], + model=main_info.get("model"), + model_id=main_info.get("model_id"), + ) diff --git a/homeassistant/components/bsblan/helpers.py b/homeassistant/components/bsblan/helpers.py index 236d4825b7e..fea6f6a878e 100644 --- a/homeassistant/components/bsblan/helpers.py +++ b/homeassistant/components/bsblan/helpers.py @@ -1,7 +1,5 @@ """Helper functions for BSB-Lan integration.""" -from __future__ import annotations - from bsblan import BSBLAN, BSBLANError from homeassistant.exceptions import HomeAssistantError diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 97423b009c4..4b65dca61b5 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["bsblan"], "quality_scale": "silver", - "requirements": ["python-bsblan==5.1.3"], + "requirements": ["python-bsblan==5.2.0"], "zeroconf": [ { "name": "bsb-lan*", diff --git a/homeassistant/components/bsblan/quality_scale.yaml b/homeassistant/components/bsblan/quality_scale.yaml index be9efefd137..f309f63765c 100644 --- a/homeassistant/components/bsblan/quality_scale.yaml +++ b/homeassistant/components/bsblan/quality_scale.yaml @@ -48,13 +48,10 @@ rules: dynamic-devices: status: exempt comment: | - This integration has a fixed single device. + Devices and sub-devices are determined at config entry setup and do not change at runtime. entity-category: done entity-device-class: done - entity-disabled-by-default: - status: exempt - comment: | - This integration provides a limited number of entities, all of which are useful to users. + entity-disabled-by-default: done entity-translations: done exception-translations: done icon-translations: todo @@ -66,7 +63,7 @@ rules: stale-devices: status: exempt comment: | - This integration has a fixed single device. + Devices and sub-devices are determined at config entry setup and do not change at runtime. # Platinum async-dependency: done diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py index 72f3fbab2d0..8688f72c9c1 100644 --- a/homeassistant/components/bsblan/sensor.py +++ b/homeassistant/components/bsblan/sensor.py @@ -1,7 +1,5 @@ """Support for BSB-LAN sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/bsblan/services.py b/homeassistant/components/bsblan/services.py index 62336f715c9..f111c84dce6 100644 --- a/homeassistant/components/bsblan/services.py +++ b/homeassistant/components/bsblan/services.py @@ -1,7 +1,5 @@ """Support for BSB-LAN services.""" -from __future__ import annotations - from datetime import time import logging from typing import TYPE_CHECKING diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index bd663eb8ba7..d257119f2a5 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -79,6 +79,14 @@ } } }, + "device": { + "heating_circuit": { + "name": "Heating circuit {circuit}" + }, + "water_heater": { + "name": "Water heater" + } + }, "entity": { "button": { "sync_time": { diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py index ec8d01b9c71..78c2e2c9363 100644 --- a/homeassistant/components/bsblan/water_heater.py +++ b/homeassistant/components/bsblan/water_heater.py @@ -1,10 +1,8 @@ """BSBLAN platform to control a compatible Water Heater Device.""" -from __future__ import annotations - from typing import Any -from bsblan import BSBLANError, SetHotWaterParam +from bsblan import BSBLANError, HotWaterState, SetHotWaterParam from homeassistant.components.water_heater import ( STATE_ECO, @@ -21,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import BSBLanConfigEntry, BSBLanData from .const import DOMAIN -from .entity import BSBLanDualCoordinatorEntity +from .entity import BSBLanWaterHeaterDeviceEntity PARALLEL_UPDATES = 1 @@ -46,8 +44,10 @@ async def async_setup_entry( data = entry.runtime_data # Only create water heater entity if DHW (Domestic Hot Water) is available - # Check if we have any DHW-related data indicating water heater support dhw_data = data.fast_coordinator.data.dhw + if dhw_data is None: + # Device does not support DHW, skip water heater setup + return if ( dhw_data.operating_mode is None and dhw_data.nominal_setpoint is None @@ -59,7 +59,7 @@ async def async_setup_entry( async_add_entities([BSBLANWaterHeater(data)]) -class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity): +class BSBLANWaterHeater(BSBLanWaterHeaterDeviceEntity, WaterHeaterEntity): """Defines a BSBLAN water heater entity.""" _attr_name = None @@ -107,11 +107,21 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity): else: self._attr_max_temp = 65.0 # Default maximum + @property + def _dhw(self) -> HotWaterState: + """Return DHW state data. + + This entity is only created when DHW data is available. + """ + dhw = self.coordinator.data.dhw + assert dhw is not None + return dhw + @property def current_operation(self) -> str | None: """Return current operation.""" if ( - operating_mode := self.coordinator.data.dhw.operating_mode + operating_mode := self._dhw.operating_mode ) is None or operating_mode.value is None: return None return BSBLAN_TO_HA_OPERATION_MODE.get(operating_mode.value) @@ -119,16 +129,14 @@ class BSBLANWaterHeater(BSBLanDualCoordinatorEntity, WaterHeaterEntity): @property def current_temperature(self) -> float | None: """Return the current temperature.""" - if ( - current_temp := self.coordinator.data.dhw.dhw_actual_value_top_temperature - ) is None: + if (current_temp := self._dhw.dhw_actual_value_top_temperature) is None: return None return current_temp.value @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - if (target_temp := self.coordinator.data.dhw.nominal_setpoint) is None: + if (target_temp := self._dhw.nominal_setpoint) is None: return None return target_temp.value diff --git a/homeassistant/components/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py index 84450573989..fd264b9b6b5 100644 --- a/homeassistant/components/bt_home_hub_5/device_tracker.py +++ b/homeassistant/components/bt_home_hub_5/device_tracker.py @@ -1,7 +1,5 @@ """Support for BT Home Hub 5.""" -from __future__ import annotations - import logging import bthomehub5_devicelist diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py index 57ceb01700d..9f5b6e8ff52 100644 --- a/homeassistant/components/bt_smarthub/device_tracker.py +++ b/homeassistant/components/bt_smarthub/device_tracker.py @@ -1,7 +1,5 @@ """Support for BT Smart Hub (Sometimes referred to as BT Home Hub 6).""" -from __future__ import annotations - from collections import namedtuple import logging diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index 5464d6ccf98..7f898d6ba1a 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -1,7 +1,5 @@ """The BTHome Bluetooth integration.""" -from __future__ import annotations - from functools import partial import logging diff --git a/homeassistant/components/bthome/binary_sensor.py b/homeassistant/components/bthome/binary_sensor.py index 0fd1b88f0fc..58546fb50c7 100644 --- a/homeassistant/components/bthome/binary_sensor.py +++ b/homeassistant/components/bthome/binary_sensor.py @@ -1,7 +1,5 @@ """Support for BTHome binary sensors.""" -from __future__ import annotations - from bthome_ble import ( BinarySensorDeviceClass as BTHomeBinarySensorDeviceClass, SensorUpdate, diff --git a/homeassistant/components/bthome/config_flow.py b/homeassistant/components/bthome/config_flow.py index 524365c1183..6340f8e4263 100644 --- a/homeassistant/components/bthome/config_flow.py +++ b/homeassistant/components/bthome/config_flow.py @@ -1,7 +1,5 @@ """Config flow for BTHome Bluetooth integration.""" -from __future__ import annotations - from collections.abc import Mapping import dataclasses from typing import Any diff --git a/homeassistant/components/bthome/const.py b/homeassistant/components/bthome/const.py index 3e7deac9303..f425fa394d3 100644 --- a/homeassistant/components/bthome/const.py +++ b/homeassistant/components/bthome/const.py @@ -1,7 +1,5 @@ """Constants for the BTHome Bluetooth integration.""" -from __future__ import annotations - from typing import Final, TypedDict DOMAIN = "bthome" diff --git a/homeassistant/components/bthome/device.py b/homeassistant/components/bthome/device.py index 1afe558db42..b5404731a70 100644 --- a/homeassistant/components/bthome/device.py +++ b/homeassistant/components/bthome/device.py @@ -1,7 +1,5 @@ """Support for BTHome Bluetooth devices.""" -from __future__ import annotations - from bthome_ble import DeviceKey from homeassistant.components.bluetooth.passive_update_processor import ( diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index f8a95c1f6f5..629c7baf23f 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for BTHome BLE.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any import voluptuous as vol diff --git a/homeassistant/components/bthome/event.py b/homeassistant/components/bthome/event.py index 99799819e43..a5c1af3d3c4 100644 --- a/homeassistant/components/bthome/event.py +++ b/homeassistant/components/bthome/event.py @@ -1,7 +1,5 @@ """Support for bthome event entities.""" -from __future__ import annotations - from dataclasses import replace from homeassistant.components.event import ( diff --git a/homeassistant/components/bthome/logbook.py b/homeassistant/components/bthome/logbook.py index 1c41d5553da..5153cf4f7b1 100644 --- a/homeassistant/components/bthome/logbook.py +++ b/homeassistant/components/bthome/logbook.py @@ -1,7 +1,5 @@ """Describe bthome 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/bthome/repairs.py b/homeassistant/components/bthome/repairs.py index 4985bcd4e51..d8d0bd53ad0 100644 --- a/homeassistant/components/bthome/repairs.py +++ b/homeassistant/components/bthome/repairs.py @@ -1,7 +1,5 @@ """Repairs for the BTHome integration.""" -from __future__ import annotations - from typing import Any from homeassistant import data_entry_flow diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index dd3ce4c560e..1bdc48be305 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -1,7 +1,5 @@ """Support for BTHome sensors.""" -from __future__ import annotations - from typing import cast from bthome_ble import SensorDeviceClass as BTHomeSensorDeviceClass, SensorUpdate, Units diff --git a/homeassistant/components/buienradar/__init__.py b/homeassistant/components/buienradar/__init__.py index bea0102be40..319aa7cb144 100644 --- a/homeassistant/components/buienradar/__init__.py +++ b/homeassistant/components/buienradar/__init__.py @@ -1,7 +1,5 @@ """The buienradar 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/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 15d08281911..73fa0430796 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -1,7 +1,5 @@ """Provide animated GIF loops of Buienradar imagery.""" -from __future__ import annotations - import asyncio from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/buienradar/config_flow.py b/homeassistant/components/buienradar/config_flow.py index 12f292036df..f0e24a32d38 100644 --- a/homeassistant/components/buienradar/config_flow.py +++ b/homeassistant/components/buienradar/config_flow.py @@ -1,7 +1,5 @@ """Config flow for buienradar integration.""" -from __future__ import annotations - import copy from typing import Any, cast diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index b32e630ef5c..79fa9b3d80b 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -1,7 +1,5 @@ """Support for Buienradar.nl weather service.""" -from __future__ import annotations - import logging from buienradar.constants import ( diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index c6b90945329..6ca286961a3 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -1,7 +1,5 @@ """Component to pressing a button as platforms.""" -from __future__ import annotations - from datetime import timedelta from enum import StrEnum import logging diff --git a/homeassistant/components/button/device_action.py b/homeassistant/components/button/device_action.py index 30c0cc36835..26ce58443d4 100644 --- a/homeassistant/components/button/device_action.py +++ b/homeassistant/components/button/device_action.py @@ -1,7 +1,5 @@ """Provides device actions for Button.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import async_validate_entity_schema diff --git a/homeassistant/components/button/device_trigger.py b/homeassistant/components/button/device_trigger.py index f1028a0ca6a..975a98136db 100644 --- a/homeassistant/components/button/device_trigger.py +++ b/homeassistant/components/button/device_trigger.py @@ -1,7 +1,5 @@ """Provides device triggers for Button.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA diff --git a/homeassistant/components/button/trigger.py b/homeassistant/components/button/trigger.py index ea69b06b511..8d5402401c7 100644 --- a/homeassistant/components/button/trigger.py +++ b/homeassistant/components/button/trigger.py @@ -1,36 +1,16 @@ """Provides triggers for buttons.""" -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 ButtonPressedTrigger(EntityTriggerBase): +class ButtonPressedTrigger(StatelessEntityTriggerBase): """Trigger for button entity presses.""" _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 button is pressed - # 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/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 8e64fdecefd..85ae72376dd 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -1,7 +1,5 @@ """Support for WebDav Calendar.""" -from __future__ import annotations - from datetime import datetime from functools import partial import logging diff --git a/homeassistant/components/caldav/coordinator.py b/homeassistant/components/caldav/coordinator.py index c6bbd15bdff..8cf66b6a1b1 100644 --- a/homeassistant/components/caldav/coordinator.py +++ b/homeassistant/components/caldav/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for caldav.""" -from __future__ import annotations - from datetime import date, datetime, time, timedelta from functools import partial import logging diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py index 73f172dabec..5d507c269b1 100644 --- a/homeassistant/components/caldav/todo.py +++ b/homeassistant/components/caldav/todo.py @@ -1,7 +1,5 @@ """CalDAV todo platform.""" -from __future__ import annotations - import asyncio from datetime import date, datetime, timedelta from functools import partial diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index db49440d449..1e9584a8064 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -1,7 +1,5 @@ """Support for Calendar event device sensors.""" -from __future__ import annotations - from collections.abc import Callable, Iterable import dataclasses import datetime @@ -15,8 +13,12 @@ from aiohttp import web from dateutil.rrule import rrulestr import voluptuous as vol +from homeassistant.auth.models import User +from homeassistant.auth.permissions.const import POLICY_CONTROL, POLICY_READ from homeassistant.components import frontend, http, websocket_api +from homeassistant.components.http import KEY_HASS_USER from homeassistant.components.websocket_api import ( + ERR_INVALID_FORMAT, ERR_NOT_FOUND, ERR_NOT_SUPPORTED, ActiveConnection, @@ -31,8 +33,9 @@ from homeassistant.core import ( SupportsResponse, callback, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_time @@ -76,6 +79,7 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE SCAN_INTERVAL = datetime.timedelta(seconds=60) +EVENT_LISTENER_DEBOUNCE_COOLDOWN = 1.0 # seconds # Don't support rrules more often than daily VALID_FREQS = {"DAILY", "WEEKLY", "MONTHLY", "YEARLY"} @@ -320,6 +324,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, handle_calendar_event_create) websocket_api.async_register_command(hass, handle_calendar_event_delete) websocket_api.async_register_command(hass, handle_calendar_event_update) + websocket_api.async_register_command(hass, handle_calendar_event_subscribe) component.async_register_entity_service( CREATE_EVENT_SERVICE, @@ -517,6 +522,17 @@ class CalendarEntity(Entity): _entity_component_unrecorded_attributes = frozenset({"description"}) _alarm_unsubs: list[CALLBACK_TYPE] | None = None + _event_listeners: ( + list[ + tuple[ + datetime.datetime, + datetime.datetime, + Callable[[list[JsonValueType] | None], None], + ] + ] + | None + ) = None + _event_listener_debouncer: Debouncer[None] | None = None _attr_initial_color: str | None @@ -585,6 +601,10 @@ class CalendarEntity(Entity): the current or upcoming event. """ super()._async_write_ha_state() + + # Notify websocket subscribers of event changes (debounced) + if self._event_listeners and self._event_listener_debouncer: + self._event_listener_debouncer.async_schedule_call() if self._alarm_unsubs is None: self._alarm_unsubs = [] _LOGGER.debug( @@ -625,6 +645,13 @@ class CalendarEntity(Entity): event.end_datetime_local, ) + @callback + def _async_cancel_event_listener_debouncer(self) -> None: + """Cancel and clear the event listener debouncer.""" + if self._event_listener_debouncer: + self._event_listener_debouncer.async_cancel() + self._event_listener_debouncer = None + async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass. @@ -633,6 +660,87 @@ class CalendarEntity(Entity): for unsub in self._alarm_unsubs or (): unsub() self._alarm_unsubs = None + self._async_cancel_event_listener_debouncer() + + @final + @callback + def async_subscribe_events( + self, + start_date: datetime.datetime, + end_date: datetime.datetime, + event_listener: Callable[[list[JsonValueType] | None], None], + ) -> CALLBACK_TYPE: + """Subscribe to calendar event updates. + + Called by websocket API. + """ + if self._event_listeners is None: + self._event_listeners = [] + + if self._event_listener_debouncer is None: + self._event_listener_debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=EVENT_LISTENER_DEBOUNCE_COOLDOWN, + immediate=True, + function=self.async_update_event_listeners, + ) + + listener_data = (start_date, end_date, event_listener) + self._event_listeners.append(listener_data) + + @callback + def unsubscribe() -> None: + if self._event_listeners: + self._event_listeners.remove(listener_data) + if not self._event_listeners: + self._async_cancel_event_listener_debouncer() + + return unsubscribe + + @final + @callback + def async_update_event_listeners(self) -> None: + """Push updated calendar events to all listeners.""" + if not self._event_listeners: + return + + for start_date, end_date, listener in self._event_listeners: + self.async_update_single_event_listener(start_date, end_date, listener) + + @final + @callback + def async_update_single_event_listener( + self, + start_date: datetime.datetime, + end_date: datetime.datetime, + listener: Callable[[list[JsonValueType] | None], None], + ) -> None: + """Schedule an event fetch and push to a single listener.""" + self.hass.async_create_task( + self._async_update_listener(start_date, end_date, listener) + ) + + async def _async_update_listener( + self, + start_date: datetime.datetime, + end_date: datetime.datetime, + listener: Callable[[list[JsonValueType] | None], None], + ) -> None: + """Fetch events and push to a single listener.""" + try: + events = await self.async_get_events(self.hass, start_date, end_date) + except HomeAssistantError as err: + _LOGGER.debug( + "Error fetching calendar events for %s: %s", + self.entity_id, + err, + ) + listener(None) + return + + event_list: list[JsonValueType] = [event.as_dict() for event in events] + listener(event_list) async def async_get_events( self, @@ -679,6 +787,10 @@ class CalendarEventView(http.HomeAssistantView): async def get(self, request: web.Request, entity_id: str) -> web.Response: """Return calendar events.""" + user: User = request[KEY_HASS_USER] + if not user.permissions.check_entity(entity_id, POLICY_READ): + raise Unauthorized(entity_id=entity_id) + if not (entity := self.component.get_entity(entity_id)) or not isinstance( entity, CalendarEntity ): @@ -730,10 +842,14 @@ class CalendarListView(http.HomeAssistantView): async def get(self, request: web.Request) -> web.Response: """Retrieve calendar list.""" + user: User = request[KEY_HASS_USER] hass = request.app[http.KEY_HASS] + entity_perm = user.permissions.check_entity calendar_list: list[dict[str, str]] = [] for entity in self.component.entities: + if not entity_perm(entity.entity_id, POLICY_READ): + continue state = hass.states.get(entity.entity_id) assert state calendar_list.append({"name": state.name, "entity_id": entity.entity_id}) @@ -753,6 +869,9 @@ async def handle_calendar_event_create( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle creation of a calendar event.""" + if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL): + raise Unauthorized(entity_id=msg["entity_id"]) + if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") return @@ -792,6 +911,8 @@ async def handle_calendar_event_delete( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle delete of a calendar event.""" + if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL): + raise Unauthorized(entity_id=msg["entity_id"]) if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") @@ -837,7 +958,10 @@ async def handle_calendar_event_delete( async def handle_calendar_event_update( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: - """Handle creation of a calendar event.""" + """Handle update of a calendar event.""" + if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL): + raise Unauthorized(entity_id=msg["entity_id"]) + if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])): connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") return @@ -867,6 +991,68 @@ async def handle_calendar_event_update( connection.send_result(msg["id"]) +@websocket_api.websocket_command( + { + vol.Required("type"): "calendar/event/subscribe", + vol.Required("entity_id"): cv.entity_domain(DOMAIN), + vol.Required("start"): cv.datetime, + vol.Required("end"): cv.datetime, + } +) +@websocket_api.async_response +async def handle_calendar_event_subscribe( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Subscribe to calendar event updates.""" + entity_id: str = msg["entity_id"] + + if not connection.user.permissions.check_entity(entity_id, POLICY_READ): + raise Unauthorized(entity_id=entity_id) + + if not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id)): + connection.send_error( + msg["id"], + ERR_NOT_FOUND, + f"Calendar entity not found: {entity_id}", + ) + return + + start_date = dt_util.as_local(msg["start"]) + end_date = dt_util.as_local(msg["end"]) + + if start_date >= end_date: + connection.send_error( + msg["id"], + ERR_INVALID_FORMAT, + "Start must be before end", + ) + return + + subscription_id = msg["id"] + + @callback + def event_listener(events: list[JsonValueType] | None) -> None: + """Push updated calendar events to websocket.""" + if subscription_id not in connection.subscriptions: + return + connection.send_message( + websocket_api.event_message( + subscription_id, + { + "events": events, + }, + ) + ) + + connection.subscriptions[subscription_id] = entity.async_subscribe_events( + start_date, end_date, event_listener + ) + connection.send_result(subscription_id) + + # Push initial events only to the new subscriber + entity.async_update_single_event_listener(start_date, end_date, event_listener) + + def _validate_timespan( values: dict[str, Any], ) -> tuple[datetime.datetime | datetime.date, datetime.datetime | datetime.date]: diff --git a/homeassistant/components/calendar/conditions.yaml b/homeassistant/components/calendar/conditions.yaml index 7452e7ec7fe..40c06cb88bc 100644 --- a/homeassistant/components/calendar/conditions.yaml +++ b/homeassistant/components/calendar/conditions.yaml @@ -7,8 +7,10 @@ is_event_active: 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: diff --git a/homeassistant/components/calendar/const.py b/homeassistant/components/calendar/const.py index 821fe24c383..f568fb83d87 100644 --- a/homeassistant/components/calendar/const.py +++ b/homeassistant/components/calendar/const.py @@ -1,7 +1,5 @@ """Constants for calendar components.""" -from __future__ import annotations - from enum import IntFlag from typing import TYPE_CHECKING diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 8cac1016e80..2f5d666904e 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -1,15 +1,17 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted calendars.", - "condition_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least" }, "conditions": { "is_event_active": { "description": "Tests if one or more calendars have an active event.", "fields": { "behavior": { - "description": "[%key:component::calendar::common::condition_behavior_description%]", "name": "[%key:component::calendar::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::calendar::common::condition_for_name%]" } }, "name": "Calendar event is active" @@ -62,12 +64,6 @@ } }, "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, "trigger_offset_type": { "options": { "after": "After", diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index 18ab33516e7..8bd84a6bbb2 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -1,7 +1,5 @@ """Offer calendar automation rules.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass diff --git a/homeassistant/components/cambridge_audio/__init__.py b/homeassistant/components/cambridge_audio/__init__.py index cdae1a6dc0c..0735b3ea7c7 100644 --- a/homeassistant/components/cambridge_audio/__init__.py +++ b/homeassistant/components/cambridge_audio/__init__.py @@ -1,7 +1,5 @@ """The Cambridge Audio integration.""" -from __future__ import annotations - import asyncio import logging diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index 75e537e457c..2bfc32089c8 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -1,7 +1,5 @@ """Support for Cambridge Audio AV Receiver.""" -from __future__ import annotations - from datetime import datetime from typing import Any diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index fb7de2d8ebd..d0264e130c2 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -1,7 +1,5 @@ """Component to interface with cameras.""" -from __future__ import annotations - import asyncio import collections from collections.abc import Awaitable, Callable, Coroutine @@ -58,7 +56,6 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.network import get_url from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, VolDictType -from homeassistant.loader import bind_hass from .const import ( CAMERA_IMAGE_TIMEOUT, @@ -163,7 +160,6 @@ class CameraCapabilities: frontend_stream_types: set[StreamType] -@bind_hass async def async_request_stream(hass: HomeAssistant, entity_id: str, fmt: str) -> str: """Request a stream for a camera entity.""" camera = get_camera_from_entity_id(hass, entity_id) @@ -212,7 +208,6 @@ async def _async_get_image( raise HomeAssistantError("Unable to get image") -@bind_hass async def async_get_image( hass: HomeAssistant, entity_id: str, @@ -247,14 +242,12 @@ async def _async_get_stream_image( return None -@bind_hass async def async_get_stream_source(hass: HomeAssistant, entity_id: str) -> str | None: """Fetch the stream source for a camera entity.""" camera = get_camera_from_entity_id(hass, entity_id) return await camera.stream_source() -@bind_hass async def async_get_mjpeg_stream( hass: HomeAssistant, request: web.Request, entity_id: str ) -> web.StreamResponse | None: @@ -931,6 +924,7 @@ async def websocket_get_prefs( vol.Optional(PREF_ORIENTATION): vol.Coerce(Orientation), } ) +@websocket_api.require_admin @websocket_api.async_response async def websocket_update_prefs( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py index 65862e66dab..05299537ece 100644 --- a/homeassistant/components/camera/const.py +++ b/homeassistant/components/camera/const.py @@ -1,7 +1,5 @@ """Constants for Camera component.""" -from __future__ import annotations - from enum import StrEnum from typing import TYPE_CHECKING, Final diff --git a/homeassistant/components/camera/helper.py b/homeassistant/components/camera/helper.py index 5e84b18dda8..d154947af77 100644 --- a/homeassistant/components/camera/helper.py +++ b/homeassistant/components/camera/helper.py @@ -1,7 +1,5 @@ """Camera helper functions.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py index 971e6804add..2d06cc2d766 100644 --- a/homeassistant/components/camera/img_util.py +++ b/homeassistant/components/camera/img_util.py @@ -1,7 +1,5 @@ """Image processing for cameras.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Literal, cast diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json index 72ccfd5b02e..31091828d0f 100644 --- a/homeassistant/components/camera/manifest.json +++ b/homeassistant/components/camera/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/camera", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.8.0"] + "requirements": ["PyTurboJPEG==1.8.3"] } diff --git a/homeassistant/components/camera/media_source.py b/homeassistant/components/camera/media_source.py index 701457afc3e..63df676d003 100644 --- a/homeassistant/components/camera/media_source.py +++ b/homeassistant/components/camera/media_source.py @@ -1,7 +1,5 @@ """Expose cameras as media sources.""" -from __future__ import annotations - import asyncio from homeassistant.components.media_player import BrowseError, MediaClass diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py index ceeb050b899..8fdafe7886d 100644 --- a/homeassistant/components/camera/prefs.py +++ b/homeassistant/components/camera/prefs.py @@ -1,7 +1,5 @@ """Preference management for camera component.""" -from __future__ import annotations - from collections.abc import Mapping from dataclasses import asdict, dataclass from typing import Final, cast diff --git a/homeassistant/components/camera/significant_change.py b/homeassistant/components/camera/significant_change.py index 5240e16376c..ba97aaaa7cc 100644 --- a/homeassistant/components/camera/significant_change.py +++ b/homeassistant/components/camera/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Camera state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index 796a5160c07..73183997f20 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -1,7 +1,5 @@ """Helper for WebRTC support.""" -from __future__ import annotations - from abc import ABC, abstractmethod import asyncio from collections.abc import Awaitable, Callable diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py index 4ea1bf48cf0..626a64a2555 100644 --- a/homeassistant/components/canary/__init__.py +++ b/homeassistant/components/canary/__init__.py @@ -1,7 +1,5 @@ """Support for Canary devices.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index 7fb8b1450e7..50d5c9c7c77 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for Canary alarm.""" -from __future__ import annotations - from typing import Any from canary.const import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, LOCATION_MODE_NIGHT diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 2fe7e9694ae..6986f5c3ff4 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -1,7 +1,5 @@ """Support for Canary camera.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Final diff --git a/homeassistant/components/canary/config_flow.py b/homeassistant/components/canary/config_flow.py index 8570a6886ac..edcdc851817 100644 --- a/homeassistant/components/canary/config_flow.py +++ b/homeassistant/components/canary/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Canary.""" -from __future__ import annotations - import logging from typing import Any, Final diff --git a/homeassistant/components/canary/coordinator.py b/homeassistant/components/canary/coordinator.py index 7c90074f81a..e83fe58cfb2 100644 --- a/homeassistant/components/canary/coordinator.py +++ b/homeassistant/components/canary/coordinator.py @@ -1,7 +1,5 @@ """Provides the Canary DataUpdateCoordinator.""" -from __future__ import annotations - import asyncio from collections.abc import ValuesView from datetime import timedelta diff --git a/homeassistant/components/canary/model.py b/homeassistant/components/canary/model.py index 261e59b8cfa..954b1c07fe0 100644 --- a/homeassistant/components/canary/model.py +++ b/homeassistant/components/canary/model.py @@ -1,7 +1,5 @@ """Constants for the Canary integration.""" -from __future__ import annotations - from collections.abc import ValuesView from typing import TypedDict diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 9643fb6805a..c97d5f07721 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -1,7 +1,5 @@ """Support for Canary sensors.""" -from __future__ import annotations - from typing import Final from canary.model import Device, Location, SensorType diff --git a/homeassistant/components/casper_glow/__init__.py b/homeassistant/components/casper_glow/__init__.py index 216379cb4a0..18368a84435 100644 --- a/homeassistant/components/casper_glow/__init__.py +++ b/homeassistant/components/casper_glow/__init__.py @@ -1,7 +1,5 @@ """The Casper Glow integration.""" -from __future__ import annotations - from pycasperglow import CasperGlow from homeassistant.components import bluetooth @@ -16,6 +14,7 @@ PLATFORMS: list[Platform] = [ Platform.BUTTON, Platform.LIGHT, Platform.SELECT, + Platform.SENSOR, ] diff --git a/homeassistant/components/casper_glow/binary_sensor.py b/homeassistant/components/casper_glow/binary_sensor.py index 9da8bcfe984..848b5cd3b60 100644 --- a/homeassistant/components/casper_glow/binary_sensor.py +++ b/homeassistant/components/casper_glow/binary_sensor.py @@ -1,10 +1,12 @@ """Casper Glow integration binary sensor platform.""" -from __future__ import annotations - from pycasperglow import GlowState -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -21,7 +23,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor platform for Casper Glow.""" - async_add_entities([CasperGlowPausedBinarySensor(entry.runtime_data)]) + async_add_entities( + [ + CasperGlowPausedBinarySensor(entry.runtime_data), + CasperGlowChargingBinarySensor(entry.runtime_data), + ] + ) class CasperGlowPausedBinarySensor(CasperGlowEntity, BinarySensorEntity): @@ -46,6 +53,34 @@ class CasperGlowPausedBinarySensor(CasperGlowEntity, BinarySensorEntity): @callback def _async_handle_state_update(self, state: GlowState) -> None: """Handle a state update from the device.""" - if state.is_paused is not None: + if state.is_paused is not None and state.is_paused != self._attr_is_on: self._attr_is_on = state.is_paused - self.async_write_ha_state() + self.async_write_ha_state() + + +class CasperGlowChargingBinarySensor(CasperGlowEntity, BinarySensorEntity): + """Binary sensor indicating whether the Casper Glow is charging.""" + + _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, coordinator: CasperGlowCoordinator) -> None: + """Initialize the charging binary sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{format_mac(coordinator.device.address)}_charging" + if coordinator.device.state.is_charging is not None: + self._attr_is_on = coordinator.device.state.is_charging + + async def async_added_to_hass(self) -> None: + """Register state update callback when entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + self._device.register_callback(self._async_handle_state_update) + ) + + @callback + def _async_handle_state_update(self, state: GlowState) -> None: + """Handle a state update from the device.""" + if state.is_charging is not None and state.is_charging != self._attr_is_on: + self._attr_is_on = state.is_charging + self.async_write_ha_state() diff --git a/homeassistant/components/casper_glow/button.py b/homeassistant/components/casper_glow/button.py index 225b5ab5416..389c74f00f4 100644 --- a/homeassistant/components/casper_glow/button.py +++ b/homeassistant/components/casper_glow/button.py @@ -1,7 +1,5 @@ """Casper Glow integration button platform.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass diff --git a/homeassistant/components/casper_glow/config_flow.py b/homeassistant/components/casper_glow/config_flow.py index ee8afe3d3cb..969cdbf85e2 100644 --- a/homeassistant/components/casper_glow/config_flow.py +++ b/homeassistant/components/casper_glow/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Casper Glow integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/casper_glow/coordinator.py b/homeassistant/components/casper_glow/coordinator.py index 576dfeda11e..ae1ce68cdc2 100644 --- a/homeassistant/components/casper_glow/coordinator.py +++ b/homeassistant/components/casper_glow/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Casper Glow integration.""" -from __future__ import annotations - import logging from bleak import BleakError diff --git a/homeassistant/components/casper_glow/diagnostics.py b/homeassistant/components/casper_glow/diagnostics.py index d581b748081..d2edb5b482b 100644 --- a/homeassistant/components/casper_glow/diagnostics.py +++ b/homeassistant/components/casper_glow/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for the Casper Glow integration.""" -from __future__ import annotations - from typing import Any from homeassistant.components import bluetooth diff --git a/homeassistant/components/casper_glow/entity.py b/homeassistant/components/casper_glow/entity.py index d7df3714f4e..f0792fd514b 100644 --- a/homeassistant/components/casper_glow/entity.py +++ b/homeassistant/components/casper_glow/entity.py @@ -1,7 +1,5 @@ """Base entity for the Casper Glow integration.""" -from __future__ import annotations - from collections.abc import Awaitable from pycasperglow import CasperGlowError diff --git a/homeassistant/components/casper_glow/light.py b/homeassistant/components/casper_glow/light.py index 686ccee4a7d..7d8ce134b66 100644 --- a/homeassistant/components/casper_glow/light.py +++ b/homeassistant/components/casper_glow/light.py @@ -1,7 +1,5 @@ """Casper Glow integration light platform.""" -from __future__ import annotations - from typing import Any from pycasperglow import GlowState diff --git a/homeassistant/components/casper_glow/manifest.json b/homeassistant/components/casper_glow/manifest.json index 1e862beae69..f1633138fc4 100644 --- a/homeassistant/components/casper_glow/manifest.json +++ b/homeassistant/components/casper_glow/manifest.json @@ -14,6 +14,6 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pycasperglow"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["pycasperglow==1.2.0"] } diff --git a/homeassistant/components/casper_glow/quality_scale.yaml b/homeassistant/components/casper_glow/quality_scale.yaml index 3d2cfceaf7c..f35784fffb3 100644 --- a/homeassistant/components/casper_glow/quality_scale.yaml +++ b/homeassistant/components/casper_glow/quality_scale.yaml @@ -45,24 +45,30 @@ rules: comment: No network discovery. discovery: done docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: todo - dynamic-devices: todo - entity-category: done - entity-device-class: + docs-use-cases: done + dynamic-devices: status: exempt - comment: No applicable device classes for binary_sensor, button, light, or select entities. - entity-disabled-by-default: todo + comment: Each config entry represents 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: todo - repair-issues: todo - stale-devices: todo + reconfiguration-flow: + status: exempt + comment: No user-configurable settings in the configuration flow. + repair-issues: + status: exempt + comment: Integration does not register repair issues. + stale-devices: + status: exempt + comment: Each config entry represents a single device. # Platinum async-dependency: done diff --git a/homeassistant/components/casper_glow/select.py b/homeassistant/components/casper_glow/select.py index 61d1446a9d3..e0b62a600ef 100644 --- a/homeassistant/components/casper_glow/select.py +++ b/homeassistant/components/casper_glow/select.py @@ -1,7 +1,5 @@ """Casper Glow integration select platform for dimming time.""" -from __future__ import annotations - from pycasperglow import GlowState from homeassistant.components.select import SelectEntity diff --git a/homeassistant/components/casper_glow/sensor.py b/homeassistant/components/casper_glow/sensor.py new file mode 100644 index 00000000000..cb5d1511aed --- /dev/null +++ b/homeassistant/components/casper_glow/sensor.py @@ -0,0 +1,132 @@ +"""Casper Glow integration sensor platform.""" + +from datetime import datetime, timedelta + +from pycasperglow import GlowState + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import utcnow +from homeassistant.util.variance import ignore_variance + +from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator +from .entity import CasperGlowEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CasperGlowConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform for Casper Glow.""" + async_add_entities( + [ + CasperGlowBatterySensor(entry.runtime_data), + CasperGlowDimmingEndTimeSensor(entry.runtime_data), + ] + ) + + +class CasperGlowBatterySensor(CasperGlowEntity, SensorEntity): + """Sensor entity for Casper Glow battery level.""" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, coordinator: CasperGlowCoordinator) -> None: + """Initialize the battery sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{format_mac(coordinator.device.address)}_battery" + if coordinator.device.state.battery_level is not None: + self._attr_native_value = coordinator.device.state.battery_level.percentage + + async def async_added_to_hass(self) -> None: + """Register state update callback when entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + self._device.register_callback(self._async_handle_state_update) + ) + + @callback + def _async_handle_state_update(self, state: GlowState) -> None: + """Handle a state update from the device.""" + if state.battery_level is not None: + new_value = state.battery_level.percentage + if new_value != self._attr_native_value: + self._attr_native_value = new_value + self.async_write_ha_state() + + +class CasperGlowDimmingEndTimeSensor(CasperGlowEntity, SensorEntity): + """Sensor entity for Casper Glow dimming end time.""" + + _attr_translation_key = "dimming_end_time" + _attr_device_class = SensorDeviceClass.TIMESTAMP + _attr_entity_registry_enabled_default = False + + def __init__(self, coordinator: CasperGlowCoordinator) -> None: + """Initialize the dimming end time sensor.""" + super().__init__(coordinator) + self._attr_unique_id = ( + f"{format_mac(coordinator.device.address)}_dimming_end_time" + ) + self._is_paused = False + self._projected_end_time = ignore_variance( + self._calculate_end_time, + timedelta(minutes=1, seconds=30), + ) + self._update_from_state(coordinator.device.state) + + @staticmethod + def _calculate_end_time(remaining_ms: int) -> datetime: + """Calculate projected dimming end time from remaining milliseconds.""" + return utcnow() + timedelta(milliseconds=remaining_ms) + + async def async_added_to_hass(self) -> None: + """Register state update callback when entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + self._device.register_callback(self._async_handle_state_update) + ) + + def _reset_projected_end_time(self) -> None: + """Clear the projected end time and reset the variance filter.""" + self._attr_native_value = None + self._projected_end_time = ignore_variance( + self._calculate_end_time, + timedelta(minutes=1, seconds=30), + ) + + @callback + def _update_from_state(self, state: GlowState) -> None: + """Update entity attributes from device state.""" + if state.is_paused is not None: + self._is_paused = state.is_paused + + if self._is_paused: + self._reset_projected_end_time() + return + + remaining_ms = state.dimming_time_remaining_ms + if not remaining_ms: + if remaining_ms == 0 or state.is_on is False: + self._reset_projected_end_time() + return + self._attr_native_value = self._projected_end_time(remaining_ms) + + @callback + def _async_handle_state_update(self, state: GlowState) -> None: + """Handle a state update from the device.""" + self._update_from_state(state) + self.async_write_ha_state() diff --git a/homeassistant/components/casper_glow/strings.json b/homeassistant/components/casper_glow/strings.json index 27a25b6ed4f..afe72eb0c01 100644 --- a/homeassistant/components/casper_glow/strings.json +++ b/homeassistant/components/casper_glow/strings.json @@ -44,6 +44,11 @@ "dimming_time": { "name": "Dimming time" } + }, + "sensor": { + "dimming_end_time": { + "name": "Dimming end time" + } } }, "exceptions": { diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index e72eb196b61..029b4305c8c 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -1,6 +1,5 @@ """Component to embed Google Cast.""" - -from __future__ import annotations +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from typing import Protocol diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index 6c33eac230f..a904550e27b 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Cast.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index 0a85a0007b3..4cccf4f6abf 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -1,7 +1,5 @@ """Consts for Cast integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING, NotRequired, TypedDict from homeassistant.util.signal_type import SignalType diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 2948c30fd1a..fb3dcab1574 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -1,7 +1,5 @@ """Helpers to deal with Cast devices.""" -from __future__ import annotations - import configparser from dataclasses import dataclass import logging @@ -65,6 +63,8 @@ class ChromecastInfo: """ cast_info = self.cast_info if self.cast_info.cast_type is None or self.cast_info.manufacturer is None: + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data unknown_models = hass.data[DOMAIN]["unknown_models"] if self.cast_info.model_name not in unknown_models: # Manufacturer and cast type is not available in mDNS data, diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py index 5db37519bdf..ffd06c7cd75 100644 --- a/homeassistant/components/cast/home_assistant_cast.py +++ b/homeassistant/components/cast/home_assistant_cast.py @@ -1,7 +1,5 @@ """Home Assistant Cast integration for Cast.""" -from __future__ import annotations - import voluptuous as vol from homeassistant import auth, config_entries, core diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 6acbb068953..4b37deb5099 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -1,6 +1,5 @@ """Provide functionality to interact with Cast devices on the network.""" - -from __future__ import annotations +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from collections.abc import Callable from contextlib import suppress diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index 2f5a0e28756..fd8b4382e06 100644 --- a/homeassistant/components/cast/strings.json +++ b/homeassistant/components/cast/strings.json @@ -44,10 +44,10 @@ }, "services": { "show_lovelace_view": { - "description": "Shows a dashboard view on a Chromecast device.", + "description": "Shows a dashboard view on a Google Cast device.", "fields": { "dashboard_path": { - "description": "The URL path of the dashboard to show, defaults to lovelace if not specified.", + "description": "The URL path of the dashboard to show, defaults to `lovelace` if not specified.", "name": "Dashboard path" }, "entity_id": { @@ -59,7 +59,7 @@ "name": "View path" } }, - "name": "Show dashboard view" + "name": "Show dashboard view via Google Cast" } } } diff --git a/homeassistant/components/ccm15/__init__.py b/homeassistant/components/ccm15/__init__.py index eae5d095ce7..c0d36811c3a 100644 --- a/homeassistant/components/ccm15/__init__.py +++ b/homeassistant/components/ccm15/__init__.py @@ -1,7 +1,5 @@ """The Midea ccm15 AC Controller integration.""" -from __future__ import annotations - from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/ccm15/config_flow.py b/homeassistant/components/ccm15/config_flow.py index c059796045c..559e3879336 100644 --- a/homeassistant/components/ccm15/config_flow.py +++ b/homeassistant/components/ccm15/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Midea ccm15 AC Controller integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/ccm15/diagnostics.py b/homeassistant/components/ccm15/diagnostics.py index c259e7f35c9..279fc86a31a 100644 --- a/homeassistant/components/ccm15/diagnostics.py +++ b/homeassistant/components/ccm15/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for CCM15.""" -from __future__ import annotations - from typing import Any from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index adf1e0e981c..2898a6a6082 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -1,7 +1,5 @@ """The cert_expiry component.""" -from __future__ import annotations - from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index f6dafa18a25..8509a395896 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Cert Expiry platform.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/cert_expiry/coordinator.py b/homeassistant/components/cert_expiry/coordinator.py index 644e3ee3d00..a3f6e9f02e3 100644 --- a/homeassistant/components/cert_expiry/coordinator.py +++ b/homeassistant/components/cert_expiry/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for cert_expiry coordinator.""" -from __future__ import annotations - from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/cert_expiry/entity.py b/homeassistant/components/cert_expiry/entity.py index f412f16fba8..7fb5bc80bc8 100644 --- a/homeassistant/components/cert_expiry/entity.py +++ b/homeassistant/components/cert_expiry/entity.py @@ -1,7 +1,5 @@ """Counter for the days until an HTTPS (TLS) certificate will expire.""" -from __future__ import annotations - from typing import Any from homeassistant.helpers.update_coordinator import CoordinatorEntity diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 3854dfc109e..4abf2dba036 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -1,7 +1,5 @@ """Counter for the days until an HTTPS (TLS) certificate will expire.""" -from __future__ import annotations - from datetime import datetime from homeassistant.components.sensor import SensorDeviceClass, SensorEntity diff --git a/homeassistant/components/chacon_dio/config_flow.py b/homeassistant/components/chacon_dio/config_flow.py index daaf38e0edc..35a00e67fcc 100644 --- a/homeassistant/components/chacon_dio/config_flow.py +++ b/homeassistant/components/chacon_dio/config_flow.py @@ -1,7 +1,5 @@ """Config flow for chacon_dio integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/channels/media_player.py b/homeassistant/components/channels/media_player.py index f6de35a4156..6dbaab95be7 100644 --- a/homeassistant/components/channels/media_player.py +++ b/homeassistant/components/channels/media_player.py @@ -1,7 +1,5 @@ """Support for interfacing with an instance of getchannels.com.""" -from __future__ import annotations - from typing import Any from pychannels import Channels diff --git a/homeassistant/components/chess_com/__init__.py b/homeassistant/components/chess_com/__init__.py index 998bd942ec4..a1e02320aa8 100644 --- a/homeassistant/components/chess_com/__init__.py +++ b/homeassistant/components/chess_com/__init__.py @@ -1,7 +1,5 @@ """The Chess.com integration.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/chess_com/config_flow.py b/homeassistant/components/chess_com/config_flow.py index 687d331b1dd..0956dbe4d50 100644 --- a/homeassistant/components/chess_com/config_flow.py +++ b/homeassistant/components/chess_com/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Chess.com integration.""" -from __future__ import annotations - import logging from typing import Any @@ -30,6 +28,7 @@ class ChessConfigFlow(ConfigFlow, domain=DOMAIN): client = ChessComClient(session=session) try: user = await client.get_player(user_input[CONF_USERNAME]) + await client.get_player_stats(user_input[CONF_USERNAME]) except NotFoundError: errors["base"] = "player_not_found" except Exception: @@ -38,7 +37,9 @@ class ChessConfigFlow(ConfigFlow, domain=DOMAIN): else: await self.async_set_unique_id(str(user.player_id)) self._abort_if_unique_id_configured() - return self.async_create_entry(title=user.name, data=user_input) + return self.async_create_entry( + title=user.name or user.username, data=user_input + ) return self.async_show_form( step_id="user", diff --git a/homeassistant/components/chess_com/diagnostics.py b/homeassistant/components/chess_com/diagnostics.py index 9df52a9834d..9844afee28d 100644 --- a/homeassistant/components/chess_com/diagnostics.py +++ b/homeassistant/components/chess_com/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Chess.com.""" -from __future__ import annotations - from dataclasses import asdict from typing import Any diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py index 6cc403817cf..737d0c9e148 100644 --- a/homeassistant/components/cisco_ios/device_tracker.py +++ b/homeassistant/components/cisco_ios/device_tracker.py @@ -1,7 +1,5 @@ """Support for Cisco IOS Routers.""" -from __future__ import annotations - import logging from pexpect import pxssh diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py index 78bbcc9edbc..3dcb7a0dc76 100644 --- a/homeassistant/components/cisco_mobility_express/device_tracker.py +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -1,7 +1,5 @@ """Support for Cisco Mobility Express.""" -from __future__ import annotations - import logging from ciscomobilityexpress.ciscome import CiscoMobilityExpress diff --git a/homeassistant/components/cisco_webex_teams/notify.py b/homeassistant/components/cisco_webex_teams/notify.py index 888af58b798..8b68626ad0e 100644 --- a/homeassistant/components/cisco_webex_teams/notify.py +++ b/homeassistant/components/cisco_webex_teams/notify.py @@ -1,7 +1,5 @@ """Cisco Webex notify component.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index d77a9ab9dda..f1d30646dbe 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -1,7 +1,5 @@ """Sensor for the CityBikes data.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging @@ -50,7 +48,9 @@ ATTR_UID = "uid" ATTR_LATITUDE = "latitude" ATTR_LONGITUDE = "longitude" ATTR_EMPTY_SLOTS = "empty_slots" +ATTR_FREE_EBIKES = "free_ebikes" ATTR_TIMESTAMP = "timestamp" +EXTRA_EBIKES = "ebikes" CONF_NETWORK = "network" CONF_STATIONS_LIST = "stations" @@ -238,5 +238,6 @@ class CityBikesStation(SensorEntity): ATTR_LATITUDE: station.latitude, ATTR_LONGITUDE: station.longitude, ATTR_EMPTY_SLOTS: station.empty_slots, + ATTR_FREE_EBIKES: station.extra.get(EXTRA_EBIKES), ATTR_TIMESTAMP: station.timestamp, } diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py index 4554a959388..b7ace9beeb6 100644 --- a/homeassistant/components/clementine/media_player.py +++ b/homeassistant/components/clementine/media_player.py @@ -1,7 +1,5 @@ """Support for Clementine Music Player as media player.""" -from __future__ import annotations - from datetime import timedelta import time diff --git a/homeassistant/components/clickatell/notify.py b/homeassistant/components/clickatell/notify.py index 9a5a5160ada..add53624936 100644 --- a/homeassistant/components/clickatell/notify.py +++ b/homeassistant/components/clickatell/notify.py @@ -1,7 +1,5 @@ """Clickatell platform for notify component.""" -from __future__ import annotations - from http import HTTPStatus import logging from typing import Any diff --git a/homeassistant/components/clicksend/notify.py b/homeassistant/components/clicksend/notify.py index 53f16875d6f..bc2c2160b31 100644 --- a/homeassistant/components/clicksend/notify.py +++ b/homeassistant/components/clicksend/notify.py @@ -1,7 +1,5 @@ """Clicksend platform for notify component.""" -from __future__ import annotations - from http import HTTPStatus import json import logging diff --git a/homeassistant/components/clicksend_tts/notify.py b/homeassistant/components/clicksend_tts/notify.py index 632b76bc7be..3baa4cbe379 100644 --- a/homeassistant/components/clicksend_tts/notify.py +++ b/homeassistant/components/clicksend_tts/notify.py @@ -1,7 +1,5 @@ """clicksend_tts platform for notify component.""" -from __future__ import annotations - from http import HTTPStatus import json import logging diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 516de372683..1a82ee3edb8 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -1,7 +1,5 @@ """Provides functionality to interact with climate devices.""" -from __future__ import annotations - from datetime import timedelta import functools as ft import logging diff --git a/homeassistant/components/climate/condition.py b/homeassistant/components/climate/condition.py index 0d1b5803b59..449996bb829 100644 --- a/homeassistant/components/climate/condition.py +++ b/homeassistant/components/climate/condition.py @@ -13,8 +13,8 @@ from homeassistant.helpers.condition import ( Condition, ConditionConfig, EntityConditionBase, + EntityNumericalConditionBase, EntityNumericalConditionWithUnitBase, - make_entity_numerical_condition, make_entity_state_condition, ) from homeassistant.util.unit_conversion import TemperatureConverter @@ -59,12 +59,33 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase): _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} _unit_converter = TemperatureConverter + def _should_include(self, state: State) -> bool: + """Skip climate 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 climate entity from its state.""" # Climate entities convert temperatures to the system unit via show_temp return self._hass.config.units.temperature_unit +class ClimateTargetHumidityCondition(EntityNumericalConditionBase): + """Condition for climate target humidity.""" + + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)} + _valid_unit = "%" + + def _should_include(self, state: State) -> bool: + """Skip climate entities that do not expose a target humidity.""" + return ( + super()._should_include(state) + and state.attributes.get(ATTR_HUMIDITY) is not None + ) + + CONDITIONS: dict[str, type[Condition]] = { "is_hvac_mode": ClimateHVACModeCondition, "is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF), @@ -88,10 +109,7 @@ CONDITIONS: dict[str, type[Condition]] = { "is_heating": make_entity_state_condition( {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING ), - "target_humidity": make_entity_numerical_condition( - {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, - valid_unit="%", - ), + "target_humidity": ClimateTargetHumidityCondition, "target_temperature": ClimateTargetTemperatureCondition, } diff --git a/homeassistant/components/climate/conditions.yaml b/homeassistant/components/climate/conditions.yaml index cb1e09abac0..915cb7fcc9a 100644 --- a/homeassistant/components/climate/conditions.yaml +++ b/homeassistant/components/climate/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 @@ -49,6 +51,7 @@ is_hvac_mode: target: *condition_climate_target fields: behavior: *condition_behavior + for: *condition_for hvac_mode: context: filter_target: target @@ -64,6 +67,7 @@ target_humidity: target: *condition_climate_target fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: @@ -76,6 +80,7 @@ target_temperature: target: *condition_climate_target fields: behavior: *condition_behavior + for: *condition_for threshold: required: true selector: diff --git a/homeassistant/components/climate/device_action.py b/homeassistant/components/climate/device_action.py index c9d098d7be6..a52cdc15e79 100644 --- a/homeassistant/components/climate/device_action.py +++ b/homeassistant/components/climate/device_action.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/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index 1becbf84915..11b48639f39 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -1,7 +1,5 @@ """Provide the device automations for Climate.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 84651dd6d86..dab54f4d17b 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/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 DEVICE_TRIGGER_BASE_SCHEMA diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py index 6f820ce0837..e9f3539d6db 100644 --- a/homeassistant/components/climate/intent.py +++ b/homeassistant/components/climate/intent.py @@ -1,7 +1,5 @@ """Intents for the climate integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py index 0f965e5a9e9..9bf2131d664 100644 --- a/homeassistant/components/climate/reproduce_state.py +++ b/homeassistant/components/climate/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/climate/significant_change.py b/homeassistant/components/climate/significant_change.py index 7bc42d5dbd5..3b98c9c2fc0 100644 --- a/homeassistant/components/climate/significant_change.py +++ b/homeassistant/components/climate/significant_change.py @@ -1,7 +1,5 @@ """Helper to test significant Climate state changes.""" -from __future__ import annotations - from typing import Any from homeassistant.const import UnitOfTemperature diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 7fc608ff419..5c45a31f2c3 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -1,107 +1,118 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted climate-control devices.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted climates to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "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": { "is_cooling": { - "description": "Tests if one or more climate-control devices are cooling.", + "description": "Tests if one or more thermostats are cooling.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::climate::common::condition_for_name%]" } }, - "name": "Climate-control device is cooling" + "name": "Thermostat is cooling" }, "is_drying": { - "description": "Tests if one or more climate-control devices are drying.", + "description": "Tests if one or more thermostats are drying.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::climate::common::condition_for_name%]" } }, - "name": "Climate-control device is drying" + "name": "Thermostat is drying" }, "is_heating": { - "description": "Tests if one or more climate-control devices are heating.", + "description": "Tests if one or more thermostats are heating.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::climate::common::condition_for_name%]" } }, - "name": "Climate-control device is heating" + "name": "Thermostat is heating" }, "is_hvac_mode": { - "description": "Tests if one or more climate-control devices are set to a specific HVAC mode.", + "description": "Tests if one or more thermostats are set to a specific HVAC mode.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::climate::common::condition_for_name%]" + }, "hvac_mode": { "description": "The HVAC modes to test for.", "name": "Modes" } }, - "name": "Climate-control device HVAC mode" + "name": "Thermostat HVAC mode" }, "is_off": { - "description": "Tests if one or more climate-control devices are off.", + "description": "Tests if one or more thermostats are off.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::climate::common::condition_for_name%]" } }, - "name": "Climate-control device is off" + "name": "Thermostat is off" }, "is_on": { - "description": "Tests if one or more climate-control devices are on.", + "description": "Tests if one or more thermostats are on.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::climate::common::condition_for_name%]" } }, - "name": "Climate-control device is on" + "name": "Thermostat is on" }, "target_humidity": { - "description": "Tests the humidity setpoint of one or more climate-control devices.", + "description": "Tests the humidity setpoint of one or more thermostats.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::climate::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::climate::common::condition_threshold_description%]", "name": "[%key:component::climate::common::condition_threshold_name%]" } }, - "name": "Climate-control device target humidity" + "name": "Thermostat target humidity" }, "target_temperature": { - "description": "Tests the temperature setpoint of one or more climate-control devices.", + "description": "Tests the temperature setpoint of one or more thermostats.", "fields": { "behavior": { - "description": "[%key:component::climate::common::condition_behavior_description%]", "name": "[%key:component::climate::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::climate::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::climate::common::condition_threshold_description%]", "name": "[%key:component::climate::common::condition_threshold_name%]" } }, - "name": "Climate-control device target temperature" + "name": "Thermostat target temperature" } }, "device_automation": { @@ -254,7 +265,7 @@ "message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}." }, "low_temp_higher_than_high_temp": { - "message": "'Lower target temperature' can not be higher than 'Upper target temperature'." + "message": "'Lower target temperature' cannot be higher than 'Upper target temperature'." }, "missing_target_temperature_entity_feature": { "message": "Set temperature action was used with the 'Target temperature' parameter but the entity does not support it." @@ -281,84 +292,69 @@ "message": "Provided temperature {check_temp} is not valid. Accepted range is {min_temp} to {max_temp}." } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "set_fan_mode": { - "description": "Sets the fan mode of a climate-control device.", + "description": "Sets the fan mode of a thermostat.", "fields": { "fan_mode": { "description": "Fan operation mode.", "name": "Fan mode" } }, - "name": "Set climate-control device fan mode" + "name": "Set thermostat fan mode" }, "set_humidity": { - "description": "Sets the target humidity of a climate-control device.", + "description": "Sets the target humidity of a thermostat.", "fields": { "humidity": { "description": "Target humidity.", "name": "Humidity" } }, - "name": "Set climate-control device target humidity" + "name": "Set thermostat target humidity" }, "set_hvac_mode": { - "description": "Sets the HVAC mode of a climate-control device.", + "description": "Sets the HVAC mode of a thermostat.", "fields": { "hvac_mode": { "description": "HVAC operation mode.", "name": "HVAC mode" } }, - "name": "Set climate-control device HVAC mode" + "name": "Set thermostat HVAC mode" }, "set_preset_mode": { - "description": "Sets the preset mode of a climate-control device.", + "description": "Sets the preset mode of a thermostat.", "fields": { "preset_mode": { "description": "Preset mode.", "name": "Preset mode" } }, - "name": "Set climate-control device preset mode" + "name": "Set thermostat preset mode" }, "set_swing_horizontal_mode": { - "description": "Sets the horizontal swing mode of a climate-control device.", + "description": "Sets the horizontal swing mode of a thermostat.", "fields": { "swing_horizontal_mode": { "description": "Horizontal swing operation mode.", "name": "Horizontal swing mode" } }, - "name": "Set climate-control device horizontal swing mode" + "name": "Set thermostat horizontal swing mode" }, "set_swing_mode": { - "description": "Sets the swing mode of a climate-control device.", + "description": "Sets the swing mode of a thermostat.", "fields": { "swing_mode": { "description": "Swing operation mode.", "name": "Swing mode" } }, - "name": "Set climate-control device swing mode" + "name": "Set thermostat swing mode" }, "set_temperature": { - "description": "Sets the target temperature of a climate-control device.", + "description": "Sets the target temperature of a thermostat.", "fields": { "hvac_mode": { "description": "HVAC operation mode.", @@ -377,134 +373,146 @@ "name": "Target temperature" } }, - "name": "Set climate-control device target temperature" + "name": "Set thermostat target temperature" }, "toggle": { - "description": "Toggles a climate-control device on/off.", - "name": "Toggle climate-control device" + "description": "Toggles a thermostat on/off.", + "name": "Toggle thermostat" }, "turn_off": { - "description": "Turns off a climate-control device.", - "name": "Turn off climate-control device" + "description": "Turns off a thermostat.", + "name": "Turn off thermostat" }, "turn_on": { - "description": "Turns on a climate-control device.", - "name": "Turn on climate-control device" + "description": "Turns on a thermostat.", + "name": "Turn on thermostat" } }, "title": "Climate", "triggers": { "hvac_mode_changed": { - "description": "Triggers after the mode of one or more climate-control devices changes.", + "description": "Triggers after the mode of one or more thermostats changes.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::climate::common::trigger_for_name%]" + }, "hvac_mode": { "description": "The HVAC modes to trigger on.", "name": "Modes" } }, - "name": "Climate-control device mode changed" + "name": "Thermostat mode changed" }, "started_cooling": { - "description": "Triggers after one or more climate-control devices start cooling.", + "description": "Triggers after one or more thermostats start cooling.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::climate::common::trigger_for_name%]" } }, - "name": "Climate-control device started cooling" + "name": "Thermostat started cooling" }, "started_drying": { - "description": "Triggers after one or more climate-control devices start drying.", + "description": "Triggers after one or more thermostats start drying.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::climate::common::trigger_for_name%]" } }, - "name": "Climate-control device started drying" + "name": "Thermostat started drying" }, "started_heating": { - "description": "Triggers after one or more climate-control devices start heating.", + "description": "Triggers after one or more thermostats start heating.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::climate::common::trigger_for_name%]" } }, - "name": "Climate-control device started heating" + "name": "Thermostat started heating" }, "target_humidity_changed": { - "description": "Triggers after the humidity setpoint of one or more climate-control devices changes.", + "description": "Triggers after the humidity setpoint of one or more thermostats changes.", "fields": { "threshold": { - "description": "[%key:component::climate::common::trigger_threshold_changed_description%]", "name": "[%key:component::climate::common::trigger_threshold_name%]" } }, - "name": "Climate-control device target humidity changed" + "name": "Thermostat target humidity changed" }, "target_humidity_crossed_threshold": { - "description": "Triggers after the humidity setpoint of one or more climate-control devices crosses a threshold.", + "description": "Triggers after the humidity setpoint of one or more thermostats crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::climate::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::climate::common::trigger_threshold_crossed_description%]", "name": "[%key:component::climate::common::trigger_threshold_name%]" } }, - "name": "Climate-control device target humidity crossed threshold" + "name": "Thermostat target humidity crossed threshold" }, "target_temperature_changed": { - "description": "Triggers after the temperature setpoint of one or more climate-control devices changes.", + "description": "Triggers after the temperature setpoint of one or more thermostats changes.", "fields": { "threshold": { - "description": "[%key:component::climate::common::trigger_threshold_changed_description%]", "name": "[%key:component::climate::common::trigger_threshold_name%]" } }, - "name": "Climate-control device target temperature changed" + "name": "Thermostat target temperature changed" }, "target_temperature_crossed_threshold": { - "description": "Triggers after the temperature setpoint of one or more climate-control devices crosses a threshold.", + "description": "Triggers after the temperature setpoint of one or more thermostats crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::climate::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::climate::common::trigger_threshold_crossed_description%]", "name": "[%key:component::climate::common::trigger_threshold_name%]" } }, - "name": "Climate-control device target temperature crossed threshold" + "name": "Thermostat target temperature crossed threshold" }, "turned_off": { - "description": "Triggers after one or more climate-control devices turn off.", + "description": "Triggers after one or more thermostats turn off.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::climate::common::trigger_for_name%]" } }, - "name": "Climate-control device turned off" + "name": "Thermostat turned off" }, "turned_on": { - "description": "Triggers after one or more climate-control devices turn on, regardless of the mode.", + "description": "Triggers after one or more thermostats turn on, regardless of the mode.", "fields": { "behavior": { - "description": "[%key:component::climate::common::trigger_behavior_description%]", "name": "[%key:component::climate::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::climate::common::trigger_for_name%]" } }, - "name": "Climate-control device turned on" + "name": "Thermostat turned on" } } } diff --git a/homeassistant/components/climate/trigger.py b/homeassistant/components/climate/trigger.py index 9f9f02d7071..26c074e8b85 100644 --- a/homeassistant/components/climate/trigger.py +++ b/homeassistant/components/climate/trigger.py @@ -8,14 +8,15 @@ 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, + EntityNumericalStateChangedTriggerBase, EntityNumericalStateChangedTriggerWithUnitBase, + EntityNumericalStateCrossedThresholdTriggerBase, EntityNumericalStateCrossedThresholdTriggerWithUnitBase, + EntityNumericalStateTriggerBase, EntityNumericalStateTriggerWithUnitBase, EntityTargetStateTriggerBase, Trigger, TriggerConfig, - make_entity_numerical_state_changed_trigger, - make_entity_numerical_state_crossed_threshold_trigger, make_entity_target_state_trigger, make_entity_transition_trigger, ) @@ -55,6 +56,13 @@ class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitB _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_TEMPERATURE)} _unit_converter = TemperatureConverter + def _should_include(self, state: State) -> bool: + """Skip climate 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 climate entity from its state.""" # Climate entities convert temperatures to the system unit via show_temp @@ -75,6 +83,32 @@ class ClimateTargetTemperatureCrossedThresholdTrigger( """Trigger for climate target temperature value crossing a threshold.""" +class _ClimateTargetHumidityTriggerMixin(EntityNumericalStateTriggerBase): + """Mixin for climate target humidity triggers.""" + + _domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)} + _valid_unit = "%" + + def _should_include(self, state: State) -> bool: + """Skip climate entities that do not expose a target humidity.""" + return ( + super()._should_include(state) + and state.attributes.get(ATTR_HUMIDITY) is not None + ) + + +class ClimateTargetHumidityChangedTrigger( + _ClimateTargetHumidityTriggerMixin, EntityNumericalStateChangedTriggerBase +): + """Trigger for climate target humidity value changes.""" + + +class ClimateTargetHumidityCrossedThresholdTrigger( + _ClimateTargetHumidityTriggerMixin, EntityNumericalStateCrossedThresholdTriggerBase +): + """Trigger for climate target humidity value crossing a threshold.""" + + TRIGGERS: dict[str, type[Trigger]] = { "hvac_mode_changed": HVACModeChangedTrigger, "started_cooling": make_entity_target_state_trigger( @@ -83,14 +117,8 @@ TRIGGERS: dict[str, type[Trigger]] = { "started_drying": make_entity_target_state_trigger( {DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING ), - "target_humidity_changed": make_entity_numerical_state_changed_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, - valid_unit="%", - ), - "target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger( - {DOMAIN: DomainSpec(value_source=ATTR_HUMIDITY)}, - valid_unit="%", - ), + "target_humidity_changed": ClimateTargetHumidityChangedTrigger, + "target_humidity_crossed_threshold": ClimateTargetHumidityCrossedThresholdTrigger, "target_temperature_changed": ClimateTargetTemperatureChangedTrigger, "target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger, "turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF), diff --git a/homeassistant/components/climate/triggers.yaml b/homeassistant/components/climate/triggers.yaml index a112be84095..8bb7513c8ce 100644 --- a/homeassistant/components/climate/triggers.yaml +++ b/homeassistant/components/climate/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any 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 @@ -50,6 +51,7 @@ hvac_mode_changed: target: *trigger_climate_target fields: behavior: *trigger_behavior + for: *trigger_for hvac_mode: context: filter_target: target @@ -76,6 +78,7 @@ target_humidity_crossed_threshold: target: *trigger_climate_target fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: @@ -101,6 +104,7 @@ target_temperature_crossed_threshold: target: *trigger_climate_target fields: behavior: *trigger_behavior + for: *trigger_for threshold: required: true selector: diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 17a1ad4800d..cf509edd33b 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,7 +1,5 @@ """Component to integrate the Home Assistant cloud.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable from contextlib import suppress @@ -36,7 +34,7 @@ from homeassistant.helpers.dispatcher import ( 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 async_get_integration, bind_hass +from homeassistant.loader import async_get_integration from homeassistant.util.signal_type import SignalType # Pre-import backup to avoid it being imported @@ -181,7 +179,6 @@ class CloudConnectionState(Enum): CLOUD_DISCONNECTED = "cloud_disconnected" -@bind_hass @callback def async_is_logged_in(hass: HomeAssistant) -> bool: """Test if user is logged in. @@ -191,7 +188,6 @@ def async_is_logged_in(hass: HomeAssistant) -> bool: return DATA_CLOUD in hass.data and hass.data[DATA_CLOUD].is_logged_in -@bind_hass @callback def async_is_connected(hass: HomeAssistant) -> bool: """Test if connected to the cloud.""" @@ -207,7 +203,6 @@ def async_listen_connection_change( return async_dispatcher_connect(hass, SIGNAL_CLOUD_CONNECTION_STATE, target) -@bind_hass @callback def async_active_subscription(hass: HomeAssistant) -> bool: """Test if user has an active subscription.""" @@ -230,7 +225,6 @@ async def async_get_or_create_cloudhook(hass: HomeAssistant, webhook_id: str) -> return await async_create_cloudhook(hass, webhook_id) -@bind_hass async def async_create_cloudhook(hass: HomeAssistant, webhook_id: str) -> str: """Create a cloudhook.""" if not async_is_connected(hass): @@ -245,7 +239,6 @@ async def async_create_cloudhook(hass: HomeAssistant, webhook_id: str) -> str: return cloudhook_url -@bind_hass async def async_delete_cloudhook(hass: HomeAssistant, webhook_id: str) -> None: """Delete a cloudhook.""" if DATA_CLOUD not in hass.data: @@ -272,7 +265,6 @@ def async_listen_cloudhook_change( ) -@bind_hass @callback def async_remote_ui_url(hass: HomeAssistant) -> str: """Get the remote UI URL.""" diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index 2978a400bfd..824aa6231a1 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -1,7 +1,5 @@ """Account linking via the cloud.""" -from __future__ import annotations - from datetime import datetime import logging from typing import Any diff --git a/homeassistant/components/cloud/ai_task.py b/homeassistant/components/cloud/ai_task.py index 7123b5cd32b..0564475d810 100644 --- a/homeassistant/components/cloud/ai_task.py +++ b/homeassistant/components/cloud/ai_task.py @@ -1,7 +1,5 @@ """AI Task integration for Home Assistant Cloud.""" -from __future__ import annotations - import io from json import JSONDecodeError import logging diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 10cc54e96be..22441a42e85 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -1,7 +1,5 @@ """Alexa configuration for Home Assistant Cloud.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from contextlib import suppress diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py index fe373bb573d..35b3675e6ff 100644 --- a/homeassistant/components/cloud/backup.py +++ b/homeassistant/components/cloud/backup.py @@ -1,7 +1,5 @@ """Backup platform for the cloud integration.""" -from __future__ import annotations - import asyncio from collections.abc import AsyncIterator, Callable, Coroutine, Mapping from http import HTTPStatus diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py index 0df13fe4c7b..91c0656b7a6 100644 --- a/homeassistant/components/cloud/binary_sensor.py +++ b/homeassistant/components/cloud/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Home Assistant Cloud binary sensors.""" -from __future__ import annotations - import asyncio from typing import Any diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index b1c3bebcaae..db4aac31ce2 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -1,7 +1,5 @@ """Interface implementation for cloud client.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from datetime import datetime @@ -374,6 +372,7 @@ class CloudClient(Interface): method=payload["method"], query_string=payload["query"], mock_source=DOMAIN, + remote=None, # Remote will be used for the local_only check, but since this is from the cloud we want it to be None to mark it as non-local and bypass the ip parsing and remote checks ) response = await webhook.async_handle_webhook( diff --git a/homeassistant/components/cloud/config_flow.py b/homeassistant/components/cloud/config_flow.py index 92fbf78378b..65945d07b91 100644 --- a/homeassistant/components/cloud/config_flow.py +++ b/homeassistant/components/cloud/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Cloud integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index f69533aabe4..bb324eea1b2 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,7 +1,5 @@ """Constants for the cloud component.""" -from __future__ import annotations - import asyncio from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/cloud/conversation.py b/homeassistant/components/cloud/conversation.py index 06a11feef6e..3d0e39babe1 100644 --- a/homeassistant/components/cloud/conversation.py +++ b/homeassistant/components/cloud/conversation.py @@ -1,7 +1,5 @@ """Conversation support for Home Assistant Cloud.""" -from __future__ import annotations - from typing import Literal from homeassistant.components import conversation diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 3baea0f5b2e..ea8b2d84421 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -1,7 +1,5 @@ """Google config for Cloud.""" -from __future__ import annotations - import asyncio from http import HTTPStatus import logging diff --git a/homeassistant/components/cloud/helpers.py b/homeassistant/components/cloud/helpers.py index 61abab18c75..7795a314fb7 100644 --- a/homeassistant/components/cloud/helpers.py +++ b/homeassistant/components/cloud/helpers.py @@ -1,7 +1,5 @@ """Helpers for the cloud component.""" -from __future__ import annotations - from collections import deque import logging diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 53ed41d5b6d..280802358fa 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,7 +1,5 @@ """The HTTP api to control the cloud integration.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable, Coroutine, Mapping from contextlib import suppress @@ -615,6 +613,7 @@ class DownloadSupportPackageView(HomeAssistantView): return markdown + @require_admin async def get(self, request: web.Request) -> web.Response: """Download support package file.""" @@ -709,6 +708,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 +750,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 +810,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 +831,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( { 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..f30eb2dd150 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 diff --git a/homeassistant/components/cloud/repairs.py b/homeassistant/components/cloud/repairs.py index ed66cb8244f..748cf0ba499 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 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..c994909509a 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 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..4b013fd4dfd 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 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..035137f2731 100644 --- a/homeassistant/components/cloudflare/coordinator.py +++ b/homeassistant/components/cloudflare/coordinator.py @@ -1,7 +1,5 @@ """Contains the Coordinator for updating the IP addresses of your Cloudflare DNS records.""" -from __future__ import annotations - import asyncio from datetime import timedelta from logging import getLogger 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/config_flow.py b/homeassistant/components/cloudflare_r2/config_flow.py index 323b4ac3dec..f9a5805b7e6 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 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..88b157763df 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 diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index a79bd2493e1..f9ccbe4fd56 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 diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 4dfc744b7fa..d4aa41db651 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 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/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/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py index de2186cf7f3..0774cee5806 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 == AlarmAreaState.UNKNOWN: return False return super().available @@ -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..848742ed1de 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 != 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 == 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..0c990dedcbb 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 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 b5dbacdb66c..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.1"] + "requirements": ["aiocomelit==2.0.3"] } diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index baf5d7eff7a..1536c3bee68 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 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..551e7d40f28 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 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..3c620b21c83 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 diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 066f6ae0388..76e7f561c1f 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 = [] diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index b0031e4d5ee..e63046a1c83 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -1,30 +1,32 @@ """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.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 .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 diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 234241fdeab..2d77d9e6086 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 diff --git a/homeassistant/components/command_line/strings.json b/homeassistant/components/command_line/strings.json index 6497fdcf98d..7e569411df6 100644 --- a/homeassistant/components/command_line/strings.json +++ b/homeassistant/components/command_line/strings.json @@ -1,4 +1,10 @@ { + "issues": { + "platform_yaml_not_supported": { + "description": "Platform YAML setup is not supported.\nChange from configuring it using the `{platform}:` key to using the `command_line:` key directly in configuration.yaml and restart Home Assistant to resolve the issue.\nTo see the detailed documentation, select Learn more.", + "title": "Platform YAML is not supported in Command Line" + } + }, "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..c546e147c57 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 = [] diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py index 607340c4853..3e99f245bb2 100644 --- a/homeassistant/components/command_line/utils.py +++ b/homeassistant/components/command_line/utils.py @@ -1,14 +1,13 @@ """The command_line component utils.""" -from __future__ import annotations - import asyncio from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template -from .const import LOGGER +from .const import DOMAIN, LOGGER _EXEC_FAILED_CODE = 127 @@ -93,3 +92,19 @@ 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_issue( + hass, + DOMAIN, + f"{platform_domain}_platform_yaml_not_supported", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="platform_yaml_not_supported", + translation_placeholders={"platform": platform_domain}, + learn_more_url="https://www.home-assistant.io/integrations/command_line/", + ) diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index 36421e8ea07..2bfcf5eb9c1 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 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/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..e8e8a7e28b0 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -1,7 +1,5 @@ """Provide configuration end points for Automations.""" -from __future__ import annotations - from typing import Any import uuid diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index fe031e8466f..b37f5c9b0e8 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 diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py index b40f533d1f8..a8b7c643160 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 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 ce9f315ff78..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 @@ -210,7 +208,7 @@ def websocket_update_entity( ) return - changes = {} + changes: dict[str, Any] = {} for key in ( "area_id", 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..49eaaa2456a 100644 --- a/homeassistant/components/config/script.py +++ b/homeassistant/components/config/script.py @@ -1,7 +1,5 @@ """Provide configuration end points for scripts.""" -from __future__ import annotations - from typing import Any from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN 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..e0d7fbe9857 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 diff --git a/homeassistant/components/control4/climate.py b/homeassistant/components/control4/climate.py index ba0005cbf3a..929eaa100d2 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 diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 39360459cbd..2bcf6e2d54b 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=hass-config-flow-polling-field vol.Optional( CONF_SCAN_INTERVAL, default=self.config_entry.options.get( 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..439160169b9 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 diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py index be891c3d153..c8f87a90207 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 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..2b45bb25072 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 diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index c291a87b53d..20fee62c47b 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 diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index b279d9b9943..c8eb3c56ae0 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 diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py index 86e18f3aff0..8d940a14ac1 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 diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 7317aea8285..40629a05a16 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.5.0", "home-assistant-intents==2026.5.5"] } diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 96c245d4b27..83ce1fc156c 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 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..3a3715727c8 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 diff --git a/homeassistant/components/cookidoo/__init__.py b/homeassistant/components/cookidoo/__init__.py index 2129d1d8ed5..3a86e962c31 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 diff --git a/homeassistant/components/cookidoo/calendar.py b/homeassistant/components/cookidoo/calendar.py index 0035e225e8f..42834fadaff 100644 --- a/homeassistant/components/cookidoo/calendar.py +++ b/homeassistant/components/cookidoo/calendar.py @@ -1,7 +1,5 @@ """Calendar platform for the Cookidoo integration.""" -from __future__ import annotations - from datetime import date, datetime, timedelta import logging diff --git a/homeassistant/components/cookidoo/config_flow.py b/homeassistant/components/cookidoo/config_flow.py index 71ad3015730..8345142edfe 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 diff --git a/homeassistant/components/cookidoo/coordinator.py b/homeassistant/components/cookidoo/coordinator.py index 940c6e36f71..53eeb0a98bf 100644 --- a/homeassistant/components/cookidoo/coordinator.py +++ b/homeassistant/components/cookidoo/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for the Cookidoo integration.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import date, timedelta import logging 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/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..5b496a1ae60 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 ( 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..062dceeb660 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 diff --git a/homeassistant/components/coolmaster/config_flow.py b/homeassistant/components/coolmaster/config_flow.py index d9c16dcb7cf..727748ded69 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 diff --git a/homeassistant/components/coolmaster/coordinator.py b/homeassistant/components/coolmaster/coordinator.py index b7fe0c28134..8e1dccf7691 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 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/counter/__init__.py b/homeassistant/components/counter/__init__.py index e84a92328b2..19a5be94f87 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 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 5bede3a676b..1f08ba33ae9 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -1,19 +1,21 @@ { "common": { - "trigger_behavior_description": "The behavior of the targeted counters to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_value": { "description": "Tests the value of one or more counters.", "fields": { "behavior": { - "description": "How the state should match on the targeted counters.", - "name": "Behavior" + "name": "Condition passes if" + }, + "for": { + "name": "[%key:component::counter::common::condition_for_name%]" }, "threshold": { - "description": "What to test for and threshold values.", - "name": "Threshold" + "name": "Threshold type" } }, "name": "Counter value" @@ -45,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.", @@ -98,8 +85,10 @@ "description": "Triggers after one or more counters reach their maximum value.", "fields": { "behavior": { - "description": "[%key:component::counter::common::trigger_behavior_description%]", "name": "[%key:component::counter::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::counter::common::trigger_for_name%]" } }, "name": "Counter reached maximum" @@ -108,8 +97,10 @@ "description": "Triggers after one or more counters reach their minimum value.", "fields": { "behavior": { - "description": "[%key:component::counter::common::trigger_behavior_description%]", "name": "[%key:component::counter::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::counter::common::trigger_for_name%]" } }, "name": "Counter reached minimum" @@ -118,8 +109,10 @@ "description": "Triggers after one or more counters are reset.", "fields": { "behavior": { - "description": "[%key:component::counter::common::trigger_behavior_description%]", "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..0dcfbb81b19 100644 --- a/homeassistant/components/counter/triggers.yaml +++ b/homeassistant/components/counter/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any 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..ef638fe22e8 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) 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 30a25185aab..502168bcc79 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted covers.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted covers to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "awning_is_closed": { "description": "Tests if one or more awnings are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Awning is closed" @@ -20,8 +22,10 @@ "description": "Tests if one or more awnings are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Awning is open" @@ -30,8 +34,10 @@ "description": "Tests if one or more blinds are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Blind is closed" @@ -40,8 +46,10 @@ "description": "Tests if one or more blinds are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Blind is open" @@ -50,8 +58,10 @@ "description": "Tests if one or more curtains are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Curtain is closed" @@ -60,8 +70,10 @@ "description": "Tests if one or more curtains are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Curtain is open" @@ -70,8 +82,10 @@ "description": "Tests if one or more shades are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shade is closed" @@ -80,8 +94,10 @@ "description": "Tests if one or more shades are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shade is open" @@ -90,8 +106,10 @@ "description": "Tests if one or more shutters are closed.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shutter is closed" @@ -100,8 +118,10 @@ "description": "Tests if one or more shutters are open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::condition_behavior_description%]", "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shutter is open" @@ -190,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.", @@ -265,8 +270,10 @@ "description": "Triggers after one or more awnings close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Awning closed" @@ -275,8 +282,10 @@ "description": "Triggers after one or more awnings open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Awning opened" @@ -285,8 +294,10 @@ "description": "Triggers after one or more blinds close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Blind closed" @@ -295,8 +306,10 @@ "description": "Triggers after one or more blinds open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Blind opened" @@ -305,8 +318,10 @@ "description": "Triggers after one or more curtains close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Curtain closed" @@ -315,8 +330,10 @@ "description": "Triggers after one or more curtains open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Curtain opened" @@ -325,8 +342,10 @@ "description": "Triggers after one or more shades close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Shade closed" @@ -335,8 +354,10 @@ "description": "Triggers after one or more shades open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Shade opened" @@ -345,8 +366,10 @@ "description": "Triggers after one or more shutters close.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Shutter closed" @@ -355,8 +378,10 @@ "description": "Triggers after one or more shutters open.", "fields": { "behavior": { - "description": "[%key:component::cover::common::trigger_behavior_description%]", "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..6d8c6b1cff3 100644 --- a/homeassistant/components/cover/triggers.yaml +++ b/homeassistant/components/cover/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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..a74798f1154 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 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..2382282d808 100644 --- a/homeassistant/components/crownstone/listeners.py +++ b/homeassistant/components/crownstone/listeners.py @@ -4,8 +4,6 @@ For data updates, Cloud Push is used in form of an SSE server that sends out eve 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..69b2241e3a2 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 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..3fdac84a1bb 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 diff --git a/homeassistant/components/cync/coordinator.py b/homeassistant/components/cync/coordinator.py index 84bfa6d0fee..b9d9833e34b 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 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..e7d6d693894 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 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/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/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/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/date/strings.json b/homeassistant/components/date/strings.json index fb4976f5399..a406772a8ab 100644 --- a/homeassistant/components/date/strings.json +++ b/homeassistant/components/date/strings.json @@ -6,7 +6,7 @@ }, "services": { "set_value": { - "description": "Sets the date.", + "description": "Sets the value of a date.", "fields": { "date": { "description": "The date to set.", 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/datetime/strings.json b/homeassistant/components/datetime/strings.json index 8316bbaedb5..3fb944185f4 100644 --- a/homeassistant/components/datetime/strings.json +++ b/homeassistant/components/datetime/strings.json @@ -6,7 +6,7 @@ }, "services": { "set_value": { - "description": "Sets the date/time for a datetime entity.", + "description": "Sets the value of a date/time.", "fields": { "datetime": { "description": "The date/time to set. The time zone of the Home Assistant instance is assumed.", 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..6e4412109ea 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 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..387dc5c299c 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 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/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..b7908c6ffea 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 diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index b3c900c07c4..95f81f9a8a7 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 @@ -98,7 +99,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..0fe07caba98 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 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/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..e49e22f426b 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 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..5276946446e 100644 --- a/homeassistant/components/demo/alarm_control_panel.py +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -1,7 +1,5 @@ """Demo platform that has two fake alarm control panels.""" -from __future__ import annotations - import datetime from homeassistant.components.alarm_control_panel import AlarmControlPanelState 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..3acc9fa5b3b 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 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/__init__.py b/homeassistant/components/denon/__init__.py index ab8cd1b896e..524d9becfc7 100644 --- a/homeassistant/components/denon/__init__.py +++ b/homeassistant/components/denon/__init__.py @@ -1 +1 @@ -"""The denon component.""" +"""The Denon Network Receivers integration.""" 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..0950b902a08 --- /dev/null +++ b/homeassistant/components/denon_rs232/__init__.py @@ -0,0 +1,55 @@ +"""The Denon RS232 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 RS232 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..593dac3b821 --- /dev/null +++ b/homeassistant/components/denon_rs232/config_flow.py @@ -0,0 +1,117 @@ +"""Config flow for the Denon RS232 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 RS232.""" + + 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..a408bd33509 --- /dev/null +++ b/homeassistant/components/denon_rs232/const.py @@ -0,0 +1,12 @@ +"""Constants for the Denon RS232 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..63d177120c6 --- /dev/null +++ b/homeassistant/components/denon_rs232/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "denon_rs232", + "name": "Denon RS232", + "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..5174de45eb3 --- /dev/null +++ b/homeassistant/components/denon_rs232/media_player.py @@ -0,0 +1,233 @@ +"""Media player platform for the Denon RS232 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 RS232 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 RS232.""" + + _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/__init__.py b/homeassistant/components/denonavr/__init__.py index cd68308e124..09b084ec07c 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -1,4 +1,4 @@ -"""The denonavr component.""" +"""The Denon AVR Network Receivers integration.""" import logging 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..1077699520b 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 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..199f1db7ef0 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 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..9d5b6a61d69 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, 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..b2ec8392abe 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 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..c4987b49ffc 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..6d80500f7eb 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 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 313373e3181..4d8a92502f4 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -1,11 +1,8 @@ """Provide functionality to keep track of devices.""" -from __future__ import annotations - from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME # noqa: F401 from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass from .config_entry import ( # noqa: F401 ScannerEntity, @@ -21,6 +18,7 @@ from .const import ( # noqa: F401 ATTR_DEV_ID, ATTR_GPS, ATTR_HOST_NAME, + ATTR_IN_ZONES, ATTR_IP, ATTR_LOCATION_NAME, ATTR_MAC, @@ -51,7 +49,6 @@ from .legacy import ( # noqa: F401 ) -@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) 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 b82cf0352a7..25839956dc2 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -1,9 +1,7 @@ """Code to set up a device tracker platform using a config entry.""" -from __future__ import annotations - import asyncio -from typing import final +from typing import Any, final from propcache.api import cached_property @@ -18,7 +16,7 @@ from homeassistant.const import ( STATE_NOT_HOME, EntityCategory, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import ( DeviceInfo, @@ -33,6 +31,7 @@ from homeassistant.util.hass_dict import HassKey from .const import ( ATTR_HOST_NAME, + ATTR_IN_ZONES, ATTR_IP, ATTR_MAC, ATTR_SOURCE_TYPE, @@ -223,6 +222,9 @@ class TrackerEntity( _attr_longitude: float | None = None _attr_source_type: SourceType = SourceType.GPS + __active_zone: State | None = None + __in_zones: list[str] | None = None + @cached_property def should_poll(self) -> bool: """No polling for entities that have location pushed.""" @@ -256,6 +258,18 @@ class TrackerEntity( """Return longitude value of the device.""" return self._attr_longitude + @callback + def _async_write_ha_state(self) -> None: + """Calculate active zones.""" + if self.available and self.latitude is not None and self.longitude is not None: + self.__active_zone, self.__in_zones = zone.async_in_zones( + self.hass, self.latitude, self.longitude, self.location_accuracy + ) + else: + self.__active_zone = None + self.__in_zones = None + super()._async_write_ha_state() + @property def state(self) -> str | None: """Return the state of the device.""" @@ -263,9 +277,7 @@ class TrackerEntity( return self.location_name if self.latitude is not None and self.longitude is not None: - zone_state = zone.async_active_zone( - self.hass, self.latitude, self.longitude, self.location_accuracy - ) + zone_state = self.__active_zone if zone_state is None: state = STATE_NOT_HOME elif zone_state.entity_id == zone.ENTITY_ID_HOME: @@ -278,12 +290,13 @@ class TrackerEntity( @final @property - def state_attributes(self) -> dict[str, StateType]: + def state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" - attr: dict[str, StateType] = {} + attr: dict[str, Any] = {ATTR_IN_ZONES: []} attr.update(super().state_attributes) if self.latitude is not None and self.longitude is not None: + attr[ATTR_IN_ZONES] = self.__in_zones or [] attr[ATTR_LATITUDE] = self.latitude attr[ATTR_LONGITUDE] = self.longitude attr[ATTR_GPS_ACCURACY] = self.location_accuracy diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index c9e4d4e910a..20529dc8c33 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 @@ -43,6 +41,7 @@ ATTR_BATTERY: Final = "battery" ATTR_DEV_ID: Final = "dev_id" ATTR_GPS: Final = "gps" ATTR_HOST_NAME: Final = "host_name" +ATTR_IN_ZONES: Final = "in_zones" ATTR_LOCATION_NAME: Final = "location_name" ATTR_MAC: Final = "mac" ATTR_SOURCE_TYPE: Final = "source_type" 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..a85006d1781 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 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..a39762b661a 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 @@ -82,6 +81,8 @@ from .const import ( SourceType, ) +_LOGGER = logging.getLogger(__name__) + SERVICE_SEE: Final = "see" SOURCE_TYPES = [cls.value for cls in SourceType] @@ -128,6 +129,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.""" @@ -243,8 +246,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 +341,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} diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index f4f7031fa79..c37fa04e760 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -1,32 +1,4 @@ { - "common": { - "condition_behavior_description": "How the state should match on the targeted device trackers.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted device trackers to trigger on.", - "trigger_behavior_name": "Behavior" - }, - "conditions": { - "is_home": { - "description": "Tests if one or more device trackers are home.", - "fields": { - "behavior": { - "description": "[%key:component::device_tracker::common::condition_behavior_description%]", - "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": { - "description": "[%key:component::device_tracker::common::condition_behavior_description%]", - "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", @@ -72,21 +44,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "see": { "description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.", @@ -123,27 +80,5 @@ "name": "See device tracker" } }, - "title": "Device tracker", - "triggers": { - "entered_home": { - "description": "Triggers when one or more device trackers enter home.", - "fields": { - "behavior": { - "description": "[%key:component::device_tracker::common::trigger_behavior_description%]", - "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": { - "description": "[%key:component::device_tracker::common::trigger_behavior_description%]", - "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..f6423625c37 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 diff --git a/homeassistant/components/devolo_home_control/binary_sensor.py b/homeassistant/components/devolo_home_control/binary_sensor.py index ef80005a904..2d149b56236 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 diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index 95db596c3ef..bb3b7259765 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 diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index 64220949270..37e1dac912a 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 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..a28209d89e8 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 diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py index 907a46ec27b..5ba26f15c88 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 diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index e601728d851..b978082bc93 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -1,6 +1,6 @@ """Platform for sensor integration.""" -from __future__ import annotations +from typing import TYPE_CHECKING from devolo_home_control_api.devices.zwave import Zwave from devolo_home_control_api.homecontrol import HomeControl @@ -188,6 +188,8 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity): def sync_callback(self, message: tuple) -> None: """Update the consumption sensor state.""" if message[0] == self._attr_unique_id: + if TYPE_CHECKING: + assert self._attr_unique_id is not None self._value = getattr( self._device_instance.consumption_property[self._attr_unique_id], self._sensor_type, 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..b2e3e3dec87 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 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..1142ac8bf0f 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 @@ -117,7 +115,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/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..fd98a208c80 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 diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py index cce4b5651db..489023b5db6 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 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..cb8900c9c3b 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 diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 65687debd3a..462bcc87557 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 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/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..67f73e10e25 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=hass-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..65d62c04783 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 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..a1ff381332f 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=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from collections.abc import Callable, Coroutine 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 3487ce83c7b..ec5a9f033d2 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -1,6 +1,4 @@ -"""The dnsip component.""" - -from __future__ import annotations +"""The DNS IP integration.""" from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PORT @@ -17,7 +15,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload dnsip config entry.""" + """Unload DNS IP config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @@ -30,12 +28,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 +42,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..b2a4ff03388 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 @@ -93,7 +91,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for dnsip integration.""" VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 @staticmethod @callback @@ -133,8 +131,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, diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py index adadfd5e23d..dd5a4f38aab 100644 --- a/homeassistant/components/dnsip/sensor.py +++ b/homeassistant/components/dnsip/sensor.py @@ -1,7 +1,5 @@ """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 diff --git a/homeassistant/components/dnsip/strings.json b/homeassistant/components/dnsip/strings.json index a841cdffcde..24da52f2182 100644 --- a/homeassistant/components/dnsip/strings.json +++ b/homeassistant/components/dnsip/strings.json @@ -9,11 +9,18 @@ "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", + "port": "IPv4 port", + "port_ipv6": "IPv6 port", + "resolver": "IPv4 resolver", + "resolver_ipv6": "IPv6 resolver" + }, + "data_description": { + "hostname": "The hostname for which to perform the DNS query.", + "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." } } } @@ -50,6 +57,12 @@ "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%]" + }, + "data_description": { + "port": "[%key:component::dnsip::config::step::user::data_description::port%]", + "port_ipv6": "[%key:component::dnsip::config::step::user::data_description::port_ipv6%]", + "resolver": "[%key:component::dnsip::config::step::user::data_description::resolver%]", + "resolver_ipv6": "[%key:component::dnsip::config::step::user::data_description::resolver_ipv6%]" } } } 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 8cad12e0299..40ed892e658 100644 --- a/homeassistant/components/door/strings.json +++ b/homeassistant/components/door/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted doors.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted doors to trigger on.", - "trigger_behavior_name": "Behavior" + "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_closed": { "description": "Tests if one or more doors are closed.", "fields": { "behavior": { - "description": "[%key:component::door::common::condition_behavior_description%]", "name": "[%key:component::door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::door::common::condition_for_name%]" } }, "name": "Door is closed" @@ -20,36 +22,25 @@ "description": "Tests if one or more doors are open.", "fields": { "behavior": { - "description": "[%key:component::door::common::condition_behavior_description%]", "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": { "description": "Triggers after one or more doors close.", "fields": { "behavior": { - "description": "[%key:component::door::common::trigger_behavior_description%]", "name": "[%key:component::door::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::door::common::trigger_for_name%]" } }, "name": "Door closed" @@ -58,8 +49,10 @@ "description": "Triggers after one or more doors open.", "fields": { "behavior": { - "description": "[%key:component::door::common::trigger_behavior_description%]", "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..8f82a03c58e 100644 --- a/homeassistant/components/door/triggers.yaml +++ b/homeassistant/components/door/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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..5e1eeb0b0b8 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 diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 7a9764876fe..10100bc9374 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 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..7745dcf985d 100644 --- a/homeassistant/components/doorbird/repairs.py +++ b/homeassistant/components/doorbird/repairs.py @@ -1,7 +1,5 @@ """Repairs for DoorBird.""" -from __future__ import annotations - import voluptuous as vol from homeassistant import data_entry_flow 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..f4af711ed16 100644 --- a/homeassistant/components/downloader/services.py +++ b/homeassistant/components/downloader/services.py @@ -1,7 +1,5 @@ """Support for functionality to download files.""" -from __future__ import annotations - from http import HTTPStatus import os import re 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..8a9b28a7bc5 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 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..333320b3ae2 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 diff --git a/homeassistant/components/drop_connect/config_flow.py b/homeassistant/components/drop_connect/config_flow.py index 476b244f345..f1a6cc6003b 100644 --- a/homeassistant/components/drop_connect/config_flow.py +++ b/homeassistant/components/drop_connect/config_flow.py @@ -1,7 +1,5 @@ """Config flow for drop_connect integration.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, Any 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..d21bc693222 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 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/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..e5b25fed6fd 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 @@ -15,9 +12,9 @@ from dsmr_parser.clients.rfxtrx_protocol import ( ) from dsmr_parser.objects import DSMRObject import serial -import serial.tools.list_ports import voluptuous as vol +from homeassistant.components import usb from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, @@ -229,9 +226,7 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN): 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 - ) + dev_path = user_selection validate_data = { CONF_PORT: dev_path, @@ -242,9 +237,10 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN): 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) + ports = await usb.async_scan_serial_ports(self.hass) list_of_ports = { - port.device: f"{port}, s/n: {port.serial_number or 'n/a'}" + port.device: f"{port.device} - {port.description or 'n/a'}" + f", s/n: {port.serial_number or 'n/a'}" + (f" - {port.manufacturer}" if port.manufacturer else "") for port in ports } @@ -335,18 +331,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..4dd031f1ac4 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -3,6 +3,7 @@ "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", diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 0c4595e8f7f..7192cc94e2b 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 @@ -87,6 +85,7 @@ class MbusDeviceType(IntEnum): GAS = 3 HEAT = 4 WATER = 7 + HEAT_COOL = 12 SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( @@ -571,6 +570,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, + ), + ), } diff --git a/homeassistant/components/dsmr_reader/config_flow.py b/homeassistant/components/dsmr_reader/config_flow.py index 4f2485ec647..4bfd24a8d95 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 diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 62d095aa993..54196a10b76 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 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..55c91ce6131 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 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..0c41ff5f2a4 --- /dev/null +++ b/homeassistant/components/duco/__init__.py @@ -0,0 +1,34 @@ +"""The Duco integration.""" + +from duco import DucoClient, build_ssl_context + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import PLATFORMS +from .coordinator import DucoConfigEntry, DucoCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool: + """Set up Duco from a config entry.""" + ssl_context = await hass.async_add_executor_job(build_ssl_context) + client = DucoClient( + session=async_get_clientsession(hass), + host=entry.data[CONF_HOST], + ssl_context=ssl_context, + ) + + 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..3da6772e7a5 --- /dev/null +++ b/homeassistant/components/duco/config_flow.py @@ -0,0 +1,169 @@ +"""Config flow for the Duco integration.""" + +import logging +from typing import Any + +from duco import DucoClient, build_ssl_context +from duco.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 + +_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 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 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 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 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). + """ + ssl_context = await self.hass.async_add_executor_job(build_ssl_context) + client = DucoClient( + session=async_get_clientsession(self.hass), + host=host, + ssl_context=ssl_context, + ) + board_info = await client.async_get_board_info() + 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..5d7cf67b4d1 --- /dev/null +++ b/homeassistant/components/duco/coordinator.py @@ -0,0 +1,100 @@ +"""Data update coordinator for the Duco integration.""" + +from dataclasses import dataclass +import logging + +from duco import DucoClient +from duco.exceptions import DucoConnectionError, DucoError +from duco.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 + +_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 self.client.async_get_board_info() + 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(f"Duco API error: {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 + + try: + lan_info = await self.client.async_get_lan_info() + 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 + + return DucoData( + nodes={node.node_id: node for node in nodes}, + rssi_wifi=lan_info.rssi_wifi, + ) diff --git a/homeassistant/components/duco/diagnostics.py b/homeassistant/components/duco/diagnostics.py new file mode 100644 index 00000000000..1cf8bc66b05 --- /dev/null +++ b/homeassistant/components/duco/diagnostics.py @@ -0,0 +1,73 @@ +"""Diagnostics support for Duco.""" + +from dataclasses import asdict +from typing import Any + +from duco.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_req_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..b5c3467a292 --- /dev/null +++ b/homeassistant/components/duco/entity.py @@ -0,0 +1,50 @@ +"""Base entity for the Duco integration.""" + +from duco.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..480f2381d12 --- /dev/null +++ b/homeassistant/components/duco/fan.py @@ -0,0 +1,136 @@ +"""Fan platform for the Duco integration.""" + +import logging + +from duco.exceptions import DucoError, DucoRateLimitError +from duco.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..180733ca311 --- /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"], + "quality_scale": "platinum", + "requirements": ["python-duco-client==0.4.1"], + "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..df7ea88e622 --- /dev/null +++ b/homeassistant/components/duco/sensor.py @@ -0,0 +1,269 @@ +"""Sensor platform for the Duco integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +import logging + +from duco.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, + UnitOfTemperature, +) +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=[s.lower() for s in VentilationState], + value_fn=lambda node: ( + node.ventilation.state.lower() if node.ventilation else None + ), + node_types=(NodeType.BOX,), + ), + DucoSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda node: node.sensor.temp if node.sensor else None, + node_types=(NodeType.UCCO2, NodeType.BSRH, NodeType.UCRH), + ), + 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="box_temperature", + translation_key="box_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda node: node.sensor.temp if node.sensor 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,), + ), + 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,), + ), + 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), + ), + 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), + ), +) + +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..d44cbef2d04 --- /dev/null +++ b/homeassistant/components/duco/strings.json @@ -0,0 +1,106 @@ +{ + "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%]" + }, + "error": { + "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 {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": { + "box_temperature": { + "name": "Box temperature" + }, + "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": "Mode end time" + }, + "ventilation_state": { + "name": "Ventilation state", + "state": { + "aut1": "Automatic boost (15 min)", + "aut2": "Automatic boost (30 min)", + "aut3": "Automatic boost (45 min)", + "auto": "Automatic", + "cnt1": "Continuous low speed", + "cnt2": "Continuous medium speed", + "cnt3": "Continuous high speed", + "empt": "Empty house", + "man1": "Manual low speed (15 min)", + "man1x2": "Manual low speed (30 min)", + "man1x3": "Manual low speed (45 min)", + "man2": "Manual medium speed (15 min)", + "man2x2": "Manual medium speed (30 min)", + "man2x3": "Manual medium speed (45 min)", + "man3": "Manual high speed (15 min)", + "man3x2": "Manual high speed (30 min)", + "man3x3": "Manual high speed (45 min)" + } + } + } + }, + "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." + } + } +} 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..61866092202 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -9,8 +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 diff --git a/homeassistant/components/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json index cf92c537bc8..a4f69618775 100644 --- a/homeassistant/components/dwd_weather_warnings/strings.json +++ b/homeassistant/components/dwd_weather_warnings/strings.json @@ -5,7 +5,7 @@ "invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]" }, "error": { - "ambiguous_identifier": "The region identifier and device tracker can not be specified together.", + "ambiguous_identifier": "The region identifier and device tracker cannot be specified together.", "attribute_not_found": "The required attributes 'Latitude' and 'Longitude' were not found in the specified device tracker.", "entity_not_found": "The specified device tracker entity was not found.", "invalid_identifier": "The specified region identifier / device tracker is invalid.", diff --git a/homeassistant/components/dwd_weather_warnings/util.py b/homeassistant/components/dwd_weather_warnings/util.py index 730ebf4b71e..c2c1d65faa2 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 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..1293122ae24 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 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/entity.py b/homeassistant/components/dynalite/entity.py index 7957e9c8515..d1418bfee52 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 diff --git a/homeassistant/components/dynalite/schema.py b/homeassistant/components/dynalite/schema.py index d470243782b..81f0a3272b4 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 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/eafm/__init__.py b/homeassistant/components/eafm/__init__.py index ff1d622139a..019e5adc137 100644 --- a/homeassistant/components/eafm/__init__.py +++ b/homeassistant/components/eafm/__init__.py @@ -25,7 +25,7 @@ def _fix_device_registry_identifiers( if old_identifier not in device_entry.identifiers: # type: ignore[comparison-overlap] continue new_identifiers = device_entry.identifiers.copy() - new_identifiers.discard(old_identifier) # type: ignore[arg-type] + new_identifiers.discard(old_identifier) new_identifiers.add((DOMAIN, entry.data["station"])) device_registry.async_update_device( device_entry.id, new_identifiers=new_identifiers diff --git a/homeassistant/components/earn_e_p1/__init__.py b/homeassistant/components/earn_e_p1/__init__.py new file mode 100644 index 00000000000..ec4319c0a18 --- /dev/null +++ b/homeassistant/components/earn_e_p1/__init__.py @@ -0,0 +1,59 @@ +"""The EARN-E P1 Meter integration.""" +# pylint: disable=hass-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..68ea4d4618e 100644 --- a/homeassistant/components/easyenergy/config_flow.py +++ b/homeassistant/components/easyenergy/config_flow.py @@ -1,7 +1,5 @@ """Config flow for easyEnergy integration.""" -from __future__ import annotations - from typing import Any from homeassistant.config_entries import ConfigFlow, ConfigFlowResult 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..f39770a20ac 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 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/manifest.json b/homeassistant/components/easyenergy/manifest.json index c987e75e718..7c0c00f7607 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.0"], "single_config_entry": true } diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py index 35fab870af3..2c990ae222d 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 ( @@ -63,7 +62,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 +70,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 +78,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 +128,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 +179,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 +204,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( diff --git a/homeassistant/components/easyenergy/services.py b/homeassistant/components/easyenergy/services.py index 1ae7d5c5b5a..790595de06f 100644 --- a/homeassistant/components/easyenergy/services.py +++ b/homeassistant/components/easyenergy/services.py @@ -1,13 +1,12 @@ """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, Gas, PriceInterval, VatOption +from easyenergy.const import MARKET_TIMEZONE import voluptuous as vol from homeassistant.core import ( @@ -32,18 +31,22 @@ ATTR_INCL_VAT: Final = "incl_vat" 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" +BASE_SERVICE_SCHEMA: Final = { + vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Optional(ATTR_START): str, + vol.Optional(ATTR_END): str, +} SERVICE_SCHEMA: Final = vol.Schema( { - vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ), + **BASE_SERVICE_SCHEMA, vol.Required(ATTR_INCL_VAT): bool, - vol.Optional(ATTR_START): str, - vol.Optional(ATTR_END): str, } ) +RETURN_SERVICE_SCHEMA: Final = vol.Schema(BASE_SERVICE_SCHEMA) class PriceType(StrEnum): @@ -54,22 +57,47 @@ class PriceType(StrEnum): GAS = "gas" -def __get_date(date_input: str | None) -> date | datetime: - """Get date.""" +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: @@ -101,8 +129,8 @@ async def __get_prices( """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: @@ -112,20 +140,38 @@ async def __get_prices( if price_type == PriceType.GAS: data = await coordinator.easyenergy.gas_prices( - start_date=start, - end_date=end, + start_date=start_date, + end_date=end_date, + vat=vat, + ) + prices = data.timestamp_prices + else: + data = await coordinator.easyenergy.energy_prices( + start_date=start_date, + end_date=end_date, 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 price_type == PriceType.ENERGY_USAGE: + prices = data.timestamp_prices + 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 @@ -150,6 +196,6 @@ def async_setup_services(hass: HomeAssistant) -> None: DOMAIN, ENERGY_RETURN_SERVICE_NAME, partial(__get_prices, price_type=PriceType.ENERGY_RETURN), - schema=SERVICE_SCHEMA, + schema=RETURN_SERVICE_SCHEMA, supports_response=SupportsResponse.ONLY, ) diff --git a/homeassistant/components/ebox/__init__.py b/homeassistant/components/ebox/__init__.py index 3f807666a4b..1a482327a84 100644 --- a/homeassistant/components/ebox/__init__.py +++ b/homeassistant/components/ebox/__init__.py @@ -1 +1 @@ -"""The ebox component.""" +"""The EBox integration.""" 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/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..6cf893312f9 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 diff --git a/homeassistant/components/ecobee/entity.py b/homeassistant/components/ecobee/entity.py index 08ec1968999..ca367260bea 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 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/notify.py b/homeassistant/components/ecobee/notify.py index 2cf6a30acd7..42d449f5885 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 diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py index 50e9170394d..cdca5289527 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 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/switch.py b/homeassistant/components/ecobee/switch.py index e0848913b39..403ade5fd04 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 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..4f5e0d7e848 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.2.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/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/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..acd9a8a94c7 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 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/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 d6e268c3578..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 @@ -24,11 +22,10 @@ class EcowittEntity(Entity): self._attr_unique_id = f"{sensor.station.key}-{sensor.key}" self._attr_device_info = DeviceInfo( - identifiers={ - (DOMAIN, sensor.station.key), - }, + identifiers={(DOMAIN, sensor.station.key)}, name=sensor.station.model, model=sensor.station.model, + manufacturer="Ecowitt", sw_version=sensor.station.version, ) diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 296490511cb..a94c32cad61 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 @@ -213,11 +211,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/edimax/__init__.py b/homeassistant/components/edimax/__init__.py index 33614bf4f95..8084a10bc6b 100644 --- a/homeassistant/components/edimax/__init__.py +++ b/homeassistant/components/edimax/__init__.py @@ -1 +1 @@ -"""The edimax component.""" +"""The Edimax integration.""" 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/sensor.py b/homeassistant/components/edl21/sensor.py index 3194781d71c..5c15139a1f5 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -1,7 +1,5 @@ """Support for EDL21 Smart Meters.""" -from __future__ import annotations - from collections.abc import Mapping from datetime import timedelta from typing import Any 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..2951ad3d841 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 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..2623cef92ee 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 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..2aa2bf81446 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 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/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/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 cdf0538eea5..a4a4f759726 100644 --- a/homeassistant/components/ekeybionyx/config_flow.py +++ b/homeassistant/components/ekeybionyx/config_flow.py @@ -29,9 +29,11 @@ VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$") class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth): - """ekey bionyx authentication before a ConfigEntry exists. + """Authentication implementation used during config flow, without refresh. - This implementation directly provides the token without supporting refresh. + This exists to allow the config flow to use the API before it has fully + created a config entry required by OAuth2Session. This does not support + refreshing tokens, which is fine since it should have been just created. """ def __init__( 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..fb55a78cf95 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 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..24ffa3ebb81 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 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/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..bc14ca189e5 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 diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index 6e1baec08ef..fc71a217e74 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 diff --git a/homeassistant/components/elevenlabs/stt.py b/homeassistant/components/elevenlabs/stt.py index 76604b46317..dc5d7bb9674 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 diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index b1c26093cf9..b261884b278 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 @@ -273,7 +271,7 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): continue # Build kwargs common to both modes - kwargs = base_stream_params | { + kwargs: dict[str, Any] = base_stream_params | { "text": text, } 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..97a58e3c29c 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, 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 14bd8c55aeb..5725eea1552 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 @@ -293,7 +291,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> boo elk_temp_unit = elk.panel.temperature_units if elk_temp_unit == "C": - temperature_unit = UnitOfTemperature.CELSIUS + temperature_unit = UnitOfTemperature.CELSIUS # type: ignore[unreachable] else: temperature_unit = UnitOfTemperature.FAHRENHEIT config["temperature_unit"] = temperature_unit diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 393845f65ff..8ec116036bf 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 diff --git a/homeassistant/components/elkm1/binary_sensor.py b/homeassistant/components/elkm1/binary_sensor.py index ba6a375c29b..4637d8bea33 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 diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 59d3aa9605a..be5873a8191 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 diff --git a/homeassistant/components/elkm1/config_flow.py b/homeassistant/components/elkm1/config_flow.py index 7e1a177d4de..821647b6346 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 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..f4ca817f7e9 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 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/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..6949a915f3c 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -1,7 +1,5 @@ """Support for control of ElkM1 sensors.""" -from __future__ import annotations - from typing import Any from elkm1_lib.const import SettingFormat, ZoneType @@ -201,7 +199,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]: 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/switch.py b/homeassistant/components/elkm1/switch.py index d91d65512a2..046c0bbaf6a 100644 --- a/homeassistant/components/elkm1/switch.py +++ b/homeassistant/components/elkm1/switch.py @@ -1,7 +1,5 @@ """Support for control of ElkM1 outputs (relays).""" -from __future__ import annotations - from typing import Any from elkm1_lib.const import ThermostatMode, ThermostatSetting diff --git a/homeassistant/components/elmax/__init__.py b/homeassistant/components/elmax/__init__.py index ec293be8273..3aa8f64af04 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 diff --git a/homeassistant/components/elmax/alarm_control_panel.py b/homeassistant/components/elmax/alarm_control_panel.py index a90c8f2652c..8ed8f3c3ca6 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 diff --git a/homeassistant/components/elmax/binary_sensor.py b/homeassistant/components/elmax/binary_sensor.py index d9ec3e75901..93d7f98eb4b 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 ( 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..4a96776ab46 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 diff --git a/homeassistant/components/elmax/coordinator.py b/homeassistant/components/elmax/coordinator.py index abcc098359e..ed4707fb962 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 diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py index 6993d5e44be..d84d927955b 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 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/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..57cf9262093 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 diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index 4fb0be81814..5781460439f 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 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/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..8d584ec4425 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 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..761c3104e4b 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 @@ -715,6 +713,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 +764,11 @@ 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 + # Combined mode always emits Watts because we convert + # heterogeneous source units internally. For inverted mode the + # unit is copied from the source state in _update_state. 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..8cd3d2b9a45 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 diff --git a/homeassistant/components/energyid/quality_scale.yaml b/homeassistant/components/energyid/quality_scale.yaml index be2dd37d6fc..ff4ed64e2c9 100644 --- a/homeassistant/components/energyid/quality_scale.yaml +++ b/homeassistant/components/energyid/quality_scale.yaml @@ -6,25 +6,17 @@ rules: appropriate-polling: status: exempt comment: The integration uses a push-based mechanism with a background sync task, not polling. - brands: - status: done - common-modules: - status: done - config-flow-test-coverage: - status: done - config-flow: - status: done - dependency-transparency: - status: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done docs-actions: status: exempt comment: The integration does not expose any custom service actions. - docs-high-level-description: - status: done - docs-installation-instructions: - status: done - docs-removal-instructions: - status: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: This integration does not create its own entities. @@ -34,40 +26,30 @@ rules: has-entity-name: status: exempt comment: This integration does not create its own entities. - runtime-data: - status: done - test-before-configure: - status: done - test-before-setup: - status: done - unique-config-entry: - status: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done # Silver action-exceptions: status: exempt comment: The integration does not expose any custom service actions. - config-entry-unloading: - status: done - docs-configuration-parameters: - status: done - docs-installation-parameters: - status: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: status: exempt comment: This integration does not create its own entities. - integration-owner: - status: done + integration-owner: done log-when-unavailable: status: done comment: The integration logs a single message when the EnergyID service is unavailable. parallel-updates: status: exempt comment: This integration does not create its own entities. - reauthentication-flow: - status: done - test-coverage: - status: done + reauthentication-flow: done + test-coverage: done # Gold devices: @@ -82,21 +64,15 @@ rules: discovery-update-info: status: exempt comment: No discovery mechanism is used. - docs-data-update: - status: done - docs-examples: - status: done - docs-known-limitations: - status: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done docs-supported-devices: status: exempt comment: This is a service integration not tied to specific device models. - docs-supported-functions: - status: done - docs-troubleshooting: - status: done - docs-use-cases: - status: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: The integration creates a single device entry for the service connection. @@ -112,8 +88,7 @@ rules: entity-translations: status: exempt comment: This integration does not create its own entities. - exception-translations: - status: done + exception-translations: done icon-translations: status: exempt comment: This integration does not create its own entities. @@ -128,10 +103,8 @@ rules: comment: Creates a single service device entry tied to the config entry. # Platinum - async-dependency: - status: done - inject-websession: - status: done + async-dependency: done + inject-websession: done strict-typing: status: todo comment: Full strict typing compliance will be addressed in a future update. 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..3910532eddf 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 diff --git a/homeassistant/components/energyzero/services.py b/homeassistant/components/energyzero/services.py index 8609cb745f3..3f40ff2c570 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 diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index a3cdd1858ed..26a42929af6 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 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/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/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 57ce924733c..93a91eafdf3 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 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..57212441bb4 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 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/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index dfc7e0c7007..084a823548e 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -1,7 +1,5 @@ """Support for the Environment Canada radar imagery.""" -from __future__ import annotations - from env_canada import ECRadar import voluptuous as vol diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py index 89fc92b462e..48f53a4b6c0 100644 --- a/homeassistant/components/environment_canada/coordinator.py +++ b/homeassistant/components/environment_canada/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the Environment Canada (EC) component.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging 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/weather.py b/homeassistant/components/environment_canada/weather.py index c7d04e4c03d..777d0a90787 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 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/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..07e68ba40ab 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 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..46a0dac9779 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 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/sensor.py b/homeassistant/components/epion/sensor.py index 360c1f1d8a7..43b732392ac 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 ( 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/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..d296ed06452 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 diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index 70756c31f0f..835b4033c0f 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -1,7 +1,5 @@ """Support for ESPHome Alarm Control Panel.""" -from __future__ import annotations - from functools import partial from aioesphomeapi import ( diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 945b0714cd4..8f8b2b3fca9 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 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..0d01da15ae7 100644 --- a/homeassistant/components/esphome/bluetooth.py +++ b/homeassistant/components/esphome/bluetooth.py @@ -1,7 +1,5 @@ """Bluetooth support for esphome.""" -from __future__ import annotations - from functools import partial from typing import TYPE_CHECKING 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..29591cb379d 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 diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py index 32ba27ded8e..cde5d79446c 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -1,11 +1,18 @@ """ESPHome constants.""" -from typing import Final +from typing import TYPE_CHECKING, Final from awesomeversion import AwesomeVersion +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" 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..58b6363f910 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 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..ec78932c38c 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 diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 46059407294..a3f27f90ac0 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, 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..de11e421b6f 100644 --- a/homeassistant/components/esphome/infrared.py +++ b/homeassistant/components/esphome/infrared.py @@ -1,7 +1,5 @@ """Infrared platform for ESPHome.""" -from __future__ import annotations - from functools import partial import logging @@ -35,11 +33,7 @@ class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEn @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( 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..490e2427282 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 diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index f642dfb5694..36dc3d1c835 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==44.21.0", "esphome-dashboard-api==1.3.0", - "bleak-esphome==3.7.1" + "bleak-esphome==3.7.3" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index a35d93c9fe1..637ec20b69e 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 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..28e1000ba7b --- /dev/null +++ b/homeassistant/components/esphome/radio_frequency.py @@ -0,0 +1,75 @@ +"""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..8fe7884845e 100644 --- a/homeassistant/components/esphome/repairs.py +++ b/homeassistant/components/esphome/repairs.py @@ -1,7 +1,5 @@ """Repairs implementation for the esphome integration.""" -from __future__ import annotations - from typing import cast import voluptuous as vol 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..eae5b89901d 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 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/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..5d77a60be96 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 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..d32f2f3a55d 100644 --- a/homeassistant/components/eufylife_ble/config_flow.py +++ b/homeassistant/components/eufylife_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the EufyLife integration.""" -from __future__ import annotations - from typing import Any from eufylife_ble_client import MODEL_TO_NAME 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..ad5d3b686ad 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 diff --git a/homeassistant/components/eurotronic_cometblue/__init__.py b/homeassistant/components/eurotronic_cometblue/__init__.py new file mode 100644 index 00000000000..8976cca3e46 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/__init__.py @@ -0,0 +1,80 @@ +"""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.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..9c2889c83a6 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/climate.py @@ -0,0 +1,188 @@ +"""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..af932a822f8 --- /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 and read initial data.""" + 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..eacd5c05ccc --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/coordinator.py @@ -0,0 +1,138 @@ +"""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..e0321e409e6 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/entity.py @@ -0,0 +1,33 @@ +"""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..ce5f5033214 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "button": { + "sync_time": { + "default": "mdi:calendar-clock" + } + } + } +} 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/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..e69f84a6776 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/strings.json @@ -0,0 +1,40 @@ +{ + "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" + } + } + } +} 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 36a51edc3bc..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,10 +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, it will have been done upstream. + 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: @@ -387,9 +404,16 @@ class EvoController(EvoClimateEntity): ) -> None: """Set a Controller to any of its native operating modes.""" until = dt_util.as_utc(until) if until else None - await self.coordinator.call_client_api( - self._evo_device.set_mode(mode, until=until) - ) + try: + await self.coordinator.call_client_api( + self._evo_device.set_mode(mode, until=until) + ) + except evo.InvalidSystemModeError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_system_mode", + translation_placeholders={"error": str(err)}, + ) from err @property def hvac_mode(self) -> HVACMode: @@ -444,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..eab8a8d97a8 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,18 @@ 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 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 +39,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 33af90089a4..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 @@ -139,6 +137,9 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator): try: result = await client_api + except ec2.InvalidSystemModeError: + raise + except ec2.ApiRequestFailedError as err: self.logger.error(err) return None diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py index 0879fe739bc..4700471d23e 100644 --- a/homeassistant/components/evohome/entity.py +++ b/homeassistant/components/evohome/entity.py @@ -1,4 +1,4 @@ -"""Base for evohome entity.""" +"""Support for entities of the Evohome integration.""" from collections.abc import Mapping from datetime import UTC, datetime @@ -6,24 +6,41 @@ 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 .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 @@ -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..fa688c66a56 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-heater" + }, "set_system_mode": { "service": "mdi:pencil" }, diff --git a/homeassistant/components/evohome/services.py b/homeassistant/components/evohome/services.py index e93ccce1df2..b65a68da1b1 100644 --- a/homeassistant/components/evohome/services.py +++ b/homeassistant/components/evohome/services.py @@ -1,30 +1,55 @@ """Service handlers for the Evohome integration.""" -from __future__ import annotations - from datetime import timedelta from typing import Any, Final +from evohomeasync2 import ControlSystem from evohomeasync2.const import SZ_CAN_BE_TEMPORARY, SZ_SYSTEM_MODE, SZ_TIMING_MODE from evohomeasync2.schemas.const import ( S2_DURATION as SZ_DURATION, S2_PERIOD as SZ_PERIOD, - SystemMode as EvoSystemMode, ) 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.helpers import config_validation as cv, service +from homeassistant.exceptions import ServiceValidationError +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, + RESET_BREAKS_IN_HA_VERSION, + SERVICE_BREAKS_IN_HA_VERSION, + EvoService, +) from .coordinator import EvoDataUpdateCoordinator +from .helpers import async_create_deprecation_issue_once -# system mode schemas are built dynamically when the services are registered -# because supported modes can vary for edge-case systems +# 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, # ... 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)), + ), + vol.Exclusive(ATTR_PERIOD, "temporary"): vol.All( + 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) SET_ZONE_OVERRIDE_SCHEMA: Final[dict[str | vol.Marker, Any]] = { @@ -37,6 +62,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.""" @@ -59,16 +93,113 @@ 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.""" + + mode = data[ATTR_MODE] + tcs_modes = {m[SZ_SYSTEM_MODE]: m for m in tcs.allowed_system_modes} + + # Validation occurs here, instead of in the library, because it uses a slightly + # different schema (until instead of duration/period) for the method invoked + # via this service call + + if (mode_info := tcs_modes.get(mode)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mode_not_supported", + translation_placeholders={ATTR_MODE: mode}, + ) + + # voluptuous schema ensures that duration and period are not both present + + if not mode_info[SZ_CAN_BE_TEMPORARY]: + if ATTR_DURATION in data or ATTR_PERIOD in data: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mode_cant_be_temporary", + translation_placeholders={ATTR_MODE: mode}, + ) + return + + timing_mode = mode_info.get(SZ_TIMING_MODE) # will not be None, as can_be_temporary + + if timing_mode == SZ_DURATION and ATTR_PERIOD in data: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mode_cant_have_period", + translation_placeholders={ATTR_MODE: mode}, + ) + + if timing_mode == SZ_PERIOD and ATTR_DURATION in data: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mode_cant_have_duration", + translation_placeholders={ATTR_MODE: mode}, + ) + + @callback def setup_service_functions( hass: HomeAssistant, coordinator: EvoDataUpdateCoordinator ) -> None: - """Set up the service handlers for the system/zone operating modes. - - Not all Honeywell TCC-compatible systems support all operating modes. In addition, - each mode will require any of four distinct service schemas. This has to be - enumerated before registering the appropriate handlers. - """ + """Set up the service handlers for Evohome systems.""" @verify_domain_control(DOMAIN) async def force_refresh(call: ServiceCall) -> None: @@ -77,68 +208,48 @@ def setup_service_functions( @verify_domain_control(DOMAIN) async def set_system_mode(call: ServiceCall) -> None: - """Set the system mode.""" + """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, } async_dispatcher_send(hass, DOMAIN, payload) - assert coordinator.tcs is not None # mypy - hass.services.async_register(DOMAIN, EvoService.REFRESH_SYSTEM, force_refresh) hass.services.async_register(DOMAIN, EvoService.RESET_SYSTEM, set_system_mode) - # Enumerate which operating modes are supported by this system - modes = list(coordinator.tcs.allowed_system_modes) - - system_mode_schemas = [] - modes = [m for m in modes if m[SZ_SYSTEM_MODE] != EvoSystemMode.AUTO_WITH_RESET] - - # Permanent-only modes will use this schema - perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]] - if perm_modes: # any of: "Auto", "HeatingOff": permanent only - schema = vol.Schema({vol.Required(ATTR_MODE): vol.In(perm_modes)}) - system_mode_schemas.append(schema) - - modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]] - - # These modes are set for a number of hours (or indefinitely): use this schema - temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_DURATION] - if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours - schema = vol.Schema( - { - vol.Required(ATTR_MODE): vol.In(temp_modes), - vol.Optional(ATTR_DURATION): vol.All( - cv.time_period, - vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), - ), - } - ) - system_mode_schemas.append(schema) - - # These modes are set for a number of days (or indefinitely): use this schema - temp_modes = [m[SZ_SYSTEM_MODE] for m in modes if m[SZ_TIMING_MODE] == SZ_PERIOD] - if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days - schema = vol.Schema( - { - vol.Required(ATTR_MODE): vol.In(temp_modes), - vol.Optional(ATTR_PERIOD): vol.All( - cv.time_period, - vol.Range(min=timedelta(days=1), max=timedelta(days=99)), - ), - } - ) - system_mode_schemas.append(schema) - - if system_mode_schemas: - hass.services.async_register( - DOMAIN, - EvoService.SET_SYSTEM_MODE, - set_system_mode, - schema=vol.Schema(vol.Any(*system_mode_schemas)), - ) + hass.services.async_register( + DOMAIN, + EvoService.SET_SYSTEM_MODE, + set_system_mode, + schema=vol.Schema(SET_SYSTEM_MODE_SCHEMA), + ) _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..cf6d9fe0014 100644 --- a/homeassistant/components/evohome/storage.py +++ b/homeassistant/components/evohome/storage.py @@ -1,7 +1,5 @@ """Support for (EMEA/EU-based) Honeywell TCC systems.""" -from __future__ import annotations - from datetime import UTC, datetime, timedelta from typing import Any, NotRequired, TypedDict diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json index 6e39b24f8a6..150c1662bf8 100644 --- a/homeassistant/components/evohome/strings.json +++ b/homeassistant/components/evohome/strings.json @@ -1,12 +1,51 @@ { "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'" + }, + "mode_cant_have_duration": { + "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" + }, + "mode_not_supported": { + "message": "The mode `{mode}` is not supported by this controller" + }, "zone_only_service": { "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 the Evohome controller climate entity `entity_id`.", + "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_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": { @@ -14,29 +53,47 @@ "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..bcd4d19aa96 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, either 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/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..40c311d70f2 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 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..15712eb625c 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 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..3682d4cc57c 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 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/__init__.py b/homeassistant/components/fail2ban/__init__.py index cb2716e581d..e6af0b95b2d 100644 --- a/homeassistant/components/fail2ban/__init__.py +++ b/homeassistant/components/fail2ban/__init__.py @@ -1 +1 @@ -"""The fail2ban component.""" +"""The Fail2Ban integration.""" 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/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 efeb6efa3fc..65328d320f5 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted fans.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted fans to trigger on.", - "trigger_behavior_name": "Behavior" + "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 fans are off.", "fields": { "behavior": { - "description": "[%key:component::fan::common::condition_behavior_description%]", "name": "[%key:component::fan::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::fan::common::condition_for_name%]" } }, "name": "Fan is off" @@ -20,8 +22,10 @@ "description": "Tests if one or more fans are on.", "fields": { "behavior": { - "description": "[%key:component::fan::common::condition_behavior_description%]", "name": "[%key:component::fan::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::fan::common::condition_for_name%]" } }, "name": "Fan is on" @@ -89,24 +93,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": { @@ -199,8 +190,10 @@ "description": "Triggers after one or more fans turn off.", "fields": { "behavior": { - "description": "[%key:component::fan::common::trigger_behavior_description%]", "name": "[%key:component::fan::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::fan::common::trigger_for_name%]" } }, "name": "Fan turned off" @@ -209,8 +202,10 @@ "description": "Triggers after one or more fans turn on.", "fields": { "behavior": { - "description": "[%key:component::fan::common::trigger_behavior_description%]", "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..1eaab693936 100644 --- a/homeassistant/components/fan/triggers.yaml +++ b/homeassistant/components/fan/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any 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..d3f3a40dfef 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 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..4afa4cf8dff 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 diff --git a/homeassistant/components/feedreader/coordinator.py b/homeassistant/components/feedreader/coordinator.py index 9901bd9f1b4..38840fe8277 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 diff --git a/homeassistant/components/feedreader/event.py b/homeassistant/components/feedreader/event.py index d74550d9fd1..867d2b4def7 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 diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index d4be04deae3..c1762cc8caf 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, 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..7f530ca612d 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 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/__init__.py b/homeassistant/components/fido/__init__.py index d950d39ef70..227a03cba32 100644 --- a/homeassistant/components/fido/__init__.py +++ b/homeassistant/components/fido/__init__.py @@ -1 +1 @@ -"""The fido component.""" +"""The Fido integration.""" 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..7f8935487c0 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 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..8303d6bf0cf 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 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 6b67a657ffd..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 @@ -19,7 +17,7 @@ from pyfirefly.models import Account, Bill, Budget, Category, Currency from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -79,13 +77,13 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData] translation_placeholders={"error": repr(err)}, ) from err except FireflyConnectionError as err: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", translation_placeholders={"error": repr(err)}, ) from err except FireflyTimeoutError as err: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", translation_placeholders={"error": repr(err)}, 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..44de98d1967 100644 --- a/homeassistant/components/fireservicerota/coordinator.py +++ b/homeassistant/components/fireservicerota/coordinator.py @@ -1,7 +1,5 @@ """The FireServiceRota integration.""" -from __future__ import annotations - from datetime import timedelta import logging 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..a9532c376ae 100644 --- a/homeassistant/components/fish_audio/__init__.py +++ b/homeassistant/components/fish_audio/__init__.py @@ -1,7 +1,5 @@ """The Fish Audio integration.""" -from __future__ import annotations - import logging from fishaudio import AsyncFishAudio diff --git a/homeassistant/components/fish_audio/config_flow.py b/homeassistant/components/fish_audio/config_flow.py index 17ab9d21505..062135f8376 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 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 b04310e5706..8d44a0b686e 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -7,13 +7,14 @@ from typing import Any, cast from fitbit import Fitbit from fitbit.exceptions import HTTPException, HTTPUnauthorized -from fitbit_web_api import ApiClient, Configuration, DevicesApi +from fitbit_web_api import ApiClient, Configuration, DevicesApi, UserApi from fitbit_web_api.exceptions import ( ApiException, OpenApiException, UnauthorizedException, ) from fitbit_web_api.models.device import Device +from fitbit_web_api.models.user import User from requests.exceptions import ConnectionError as RequestsConnectionError from homeassistant.const import CONF_ACCESS_TOKEN @@ -24,7 +25,6 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from .const import FitbitUnitSystem from .exceptions import FitbitApiException, FitbitAuthException -from .model import FitbitProfile _LOGGER = logging.getLogger(__name__) @@ -46,7 +46,7 @@ class FitbitApi(ABC): ) -> None: """Initialize Fitbit auth.""" self._hass = hass - self._profile: FitbitProfile | None = None + self._profile: User | None = None self._unit_system = unit_system @abstractmethod @@ -74,18 +74,16 @@ class FitbitApi(ABC): configuration.access_token = token[CONF_ACCESS_TOKEN] return await self._hass.async_add_executor_job(ApiClient, configuration) - async def async_get_user_profile(self) -> FitbitProfile: + async def async_get_user_profile(self) -> User: """Return the user profile from the API.""" if self._profile is None: - client = await self._async_get_client() - response: dict[str, Any] = await self._run(client.user_profile_get) - _LOGGER.debug("user_profile_get=%s", response) - profile = response["user"] - self._profile = FitbitProfile( - encoded_id=profile["encodedId"], - display_name=profile["displayName"], - locale=profile.get("locale"), - ) + client = await self._async_get_fitbit_web_api() + api = UserApi(client) + api_response = await self._run_async(api.get_profile) + if not api_response.user: + raise FitbitApiException("No user profile returned from fitbit API") + _LOGGER.debug("user_profile_get=%s", api_response.to_dict()) + self._profile = api_response.user return self._profile async def async_get_unit_system(self) -> FitbitUnitSystem: diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py index d5b33a731e3..86794f5a963 100644 --- a/homeassistant/components/fitbit/config_flow.py +++ b/homeassistant/components/fitbit/config_flow.py @@ -85,4 +85,6 @@ class OAuth2FlowHandler( ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=profile.display_name, data=data) + return self.async_create_entry( + title=profile.display_name or "Fitbit", data=data + ) diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index c20854e03cf..e04d986ec78 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 diff --git a/homeassistant/components/fitbit/model.py b/homeassistant/components/fitbit/model.py index c1752616b2f..83cc47d21b0 100644 --- a/homeassistant/components/fitbit/model.py +++ b/homeassistant/components/fitbit/model.py @@ -7,20 +7,6 @@ from typing import Any from .const import CONF_CLOCK_FORMAT, CONF_MONITORED_RESOURCES, FitbitScope -@dataclass -class FitbitProfile: - """User profile from the Fitbit API response.""" - - encoded_id: str - """The ID representing the Fitbit user.""" - - display_name: str - """The name shown when the user's friends look at their Fitbit profile.""" - - locale: str | None - """The locale defined in the user's Fitbit account settings.""" - - @dataclass class FitbitConfig: """Information from the fitbit ConfigEntry data.""" diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index d8025225df5..7639d7536ce 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 @@ -25,6 +23,7 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level @@ -536,6 +535,8 @@ async def async_setup_entry( # These are run serially to reuse the cached user profile, not gathered # to avoid two racing requests. user_profile = await api.async_get_user_profile() + if user_profile.encoded_id is None: + raise ConfigEntryNotReady("Could not get user profile") unit_system = await api.async_get_unit_system() fitbit_config = config_from_entry_data(entry.data) 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..5bc40b90d3a 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -1,7 +1,5 @@ """The Fjäråskupan integration.""" -from __future__ import annotations - from collections.abc import Callable import logging @@ -12,6 +10,7 @@ from homeassistant.components.bluetooth import ( BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak, + async_discovered_service_info, async_rediscover_address, async_register_callback, ) @@ -131,3 +130,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..5bd95f7c7f6 100644 --- a/homeassistant/components/fjaraskupan/config_flow.py +++ b/homeassistant/components/fjaraskupan/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Fjäråskupan integration.""" -from __future__ import annotations - from fjaraskupan import device_filter from homeassistant.components.bluetooth import async_discovered_service_info 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/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/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/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/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..eb76cede7c4 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 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..5fcf73bddb9 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 diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index d229665ca62..d2a9ad7220d 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 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..386b0232c1f 100644 --- a/homeassistant/components/fluss/__init__.py +++ b/homeassistant/components/fluss/__init__.py @@ -1,19 +1,13 @@ """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] - - async def async_setup_entry( hass: HomeAssistant, entry: FlussConfigEntry, 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..d4480136341 100644 --- a/homeassistant/components/fluss/const.py +++ b/homeassistant/components/fluss/const.py @@ -5,5 +5,4 @@ import logging DOMAIN = "fluss" LOGGER = logging.getLogger(__name__) -UPDATE_INTERVAL = 60 # seconds -UPDATE_INTERVAL_TIMEDELTA = timedelta(seconds=UPDATE_INTERVAL) +UPDATE_INTERVAL = timedelta(minutes=30) diff --git a/homeassistant/components/fluss/coordinator.py b/homeassistant/components/fluss/coordinator.py index 6f0bc20e30f..5c2d9e67104 100644 --- a/homeassistant/components/fluss/coordinator.py +++ b/homeassistant/components/fluss/coordinator.py @@ -1,7 +1,6 @@ """DataUpdateCoordinator for Fluss+ integration.""" -from __future__ import annotations - +import asyncio from typing import Any from fluss_api import ( @@ -17,12 +16,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify -from .const import LOGGER, UPDATE_INTERVAL_TIMEDELTA +from .const import 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 +34,19 @@ 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, ) + async def _async_get_connectivity(self, device_id: str) -> bool: + """Return connectivity for a device; False if the status call fails.""" + try: + status = await self.api.async_get_device_status(device_id) + except FlussApiClientError: + return False + return status["status"]["internetConnected"] + 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 connectivity status.""" try: devices = await self.api.async_get_devices() except FlussApiClientAuthenticationError as err: @@ -47,4 +54,15 @@ 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"] + ] + connectivity = await asyncio.gather( + *(self._async_get_connectivity(d["deviceId"]) for d in device_list) + ) + return { + device["deviceId"]: {**device, "internetConnected": connected} + for device, connected in zip(device_list, connectivity, strict=False) + } diff --git a/homeassistant/components/fluss/manifest.json b/homeassistant/components/fluss/manifest.json index fcd7867ed1a..83494d8d77f 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.4"] } 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..322732a33d7 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: 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..bab770c88b3 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 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..8243acdef10 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 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..907cf51d19d 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 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 7b534b80500..bdf1700009f 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -1,15 +1,25 @@ """The Forecast.Solar integration.""" -from __future__ import annotations +from types import MappingProxyType -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError from .const import ( + CONF_AZIMUTH, CONF_DAMPING, CONF_DAMPING_EVENING, CONF_DAMPING_MORNING, + CONF_DECLINATION, CONF_MODULES_POWER, + DEFAULT_AZIMUTH, + DEFAULT_DAMPING, + DEFAULT_DECLINATION, + DEFAULT_MODULES_POWER, + DOMAIN, + SUBENTRY_TYPE_PLANE, ) from .coordinator import ForecastSolarConfigEntry, ForecastSolarDataUpdateCoordinator @@ -25,14 +35,41 @@ async def async_migrate_entry( new_options = entry.options.copy() new_options |= { CONF_MODULES_POWER: new_options.pop("modules power"), - CONF_DAMPING_MORNING: new_options.get(CONF_DAMPING, 0.0), - CONF_DAMPING_EVENING: new_options.pop(CONF_DAMPING, 0.0), + CONF_DAMPING_MORNING: new_options.get(CONF_DAMPING, DEFAULT_DAMPING), + CONF_DAMPING_EVENING: new_options.pop(CONF_DAMPING, DEFAULT_DAMPING), } hass.config_entries.async_update_entry( entry, data=entry.data, options=new_options, version=2 ) + if entry.version == 2: + # Migrate the main plane from options to a subentry + declination = entry.options.get(CONF_DECLINATION, DEFAULT_DECLINATION) + azimuth = entry.options.get(CONF_AZIMUTH, DEFAULT_AZIMUTH) + modules_power = entry.options.get(CONF_MODULES_POWER, DEFAULT_MODULES_POWER) + + subentry = ConfigSubentry( + data=MappingProxyType( + { + CONF_DECLINATION: declination, + CONF_AZIMUTH: azimuth, + CONF_MODULES_POWER: modules_power, + } + ), + subentry_type=SUBENTRY_TYPE_PLANE, + title=f"{declination}° / {azimuth}° / {modules_power}W", + unique_id=None, + ) + hass.config_entries.async_add_subentry(entry, subentry) + + new_options = dict(entry.options) + new_options.pop(CONF_DECLINATION, None) + new_options.pop(CONF_AZIMUTH, None) + new_options.pop(CONF_MODULES_POWER, None) + + hass.config_entries.async_update_entry(entry, options=new_options, version=3) + return True @@ -40,6 +77,19 @@ async def async_setup_entry( hass: HomeAssistant, entry: ForecastSolarConfigEntry ) -> bool: """Set up Forecast.Solar from a config entry.""" + plane_subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE) + if not plane_subentries: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="no_plane", + ) + + if len(plane_subentries) > 1 and not entry.options.get(CONF_API_KEY): + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="api_key_required", + ) + coordinator = ForecastSolarDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() @@ -47,9 +97,18 @@ async def async_setup_entry( 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: ForecastSolarConfigEntry +) -> None: + """Handle config entry updates (options or subentry changes).""" + hass.config_entries.async_schedule_reload(entry.entry_id) + + async def async_unload_entry( hass: HomeAssistant, entry: ForecastSolarConfigEntry ) -> bool: diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 031764a0d0a..c85201f7b0d 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 @@ -11,11 +9,13 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlowWithReload, + ConfigSubentryFlow, + 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 +from homeassistant.helpers import config_validation as cv, selector from .const import ( CONF_AZIMUTH, @@ -24,16 +24,51 @@ from .const import ( CONF_DECLINATION, CONF_INVERTER_SIZE, CONF_MODULES_POWER, + DEFAULT_AZIMUTH, + DEFAULT_DAMPING, + DEFAULT_DECLINATION, + DEFAULT_MODULES_POWER, DOMAIN, + MAX_PLANES, + SUBENTRY_TYPE_PLANE, ) RE_API_KEY = re.compile(r"^[a-zA-Z0-9]{16}$") +PLANE_SCHEMA = vol.Schema( + { + vol.Required(CONF_DECLINATION): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=90, step=1, mode=selector.NumberSelectorMode.BOX + ), + ), + vol.Coerce(int), + ), + vol.Required(CONF_AZIMUTH): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, max=360, step=1, mode=selector.NumberSelectorMode.BOX + ), + ), + vol.Coerce(int), + ), + vol.Required(CONF_MODULES_POWER): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, step=1, mode=selector.NumberSelectorMode.BOX + ), + ), + vol.Coerce(int), + ), + } +) + class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a config flow for Forecast.Solar.""" - VERSION = 2 + VERSION = 3 @staticmethod @callback @@ -43,105 +78,129 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return ForecastSolarOptionFlowHandler() + @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_PLANE: PlaneSubentryFlowHandler} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """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], }, - options={ - CONF_AZIMUTH: user_input[CONF_AZIMUTH], - CONF_DECLINATION: user_input[CONF_DECLINATION], - CONF_MODULES_POWER: user_input[CONF_MODULES_POWER], - }, + subentries=[ + { + "subentry_type": SUBENTRY_TYPE_PLANE, + "data": { + CONF_DECLINATION: user_input[CONF_DECLINATION], + 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", + "unique_id": None, + }, + ], ) return self.async_show_form( step_id="user", - data_schema=vol.Schema( + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + } + ).extend(PLANE_SCHEMA.schema), { - vol.Required( - CONF_NAME, default=self.hass.config.location_name - ): str, - vol.Required( - CONF_LATITUDE, default=self.hass.config.latitude - ): cv.latitude, - vol.Required( - CONF_LONGITUDE, default=self.hass.config.longitude - ): cv.longitude, - vol.Required(CONF_DECLINATION, default=25): vol.All( - vol.Coerce(int), vol.Range(min=0, max=90) - ), - vol.Required(CONF_AZIMUTH, default=180): vol.All( - vol.Coerce(int), vol.Range(min=0, max=360) - ), - vol.Required(CONF_MODULES_POWER): vol.All( - vol.Coerce(int), vol.Range(min=1) - ), - } + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + CONF_DECLINATION: DEFAULT_DECLINATION, + CONF_AZIMUTH: DEFAULT_AZIMUTH, + CONF_MODULES_POWER: DEFAULT_MODULES_POWER, + }, ), ) -class ForecastSolarOptionFlowHandler(OptionsFlowWithReload): +class ForecastSolarOptionFlowHandler(OptionsFlow): """Handle options.""" async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" - errors = {} + errors: dict[str, str] = {} + planes_count = len( + self.config_entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE) + ) + if user_input is not None: - if (api_key := user_input.get(CONF_API_KEY)) and RE_API_KEY.match( - api_key - ) is None: + api_key = user_input.get(CONF_API_KEY) + if planes_count > 1 and not api_key: + errors[CONF_API_KEY] = "api_key_required" + elif api_key and RE_API_KEY.match(api_key) is None: errors[CONF_API_KEY] = "invalid_api_key" else: return self.async_create_entry( title="", data=user_input | {CONF_API_KEY: api_key or None} ) + suggested_api_key = self.config_entry.options.get(CONF_API_KEY, "") + return self.async_show_form( step_id="init", data_schema=vol.Schema( { - vol.Optional( + vol.Required( CONF_API_KEY, - description={ - "suggested_value": self.config_entry.options.get( - CONF_API_KEY, "" - ) - }, + default=suggested_api_key, + ) + if planes_count > 1 + else vol.Optional( + CONF_API_KEY, + description={"suggested_value": suggested_api_key}, ): str, - vol.Required( - CONF_DECLINATION, - default=self.config_entry.options[CONF_DECLINATION], - ): vol.All(vol.Coerce(int), vol.Range(min=0, max=90)), - vol.Required( - CONF_AZIMUTH, - default=self.config_entry.options.get(CONF_AZIMUTH), - ): vol.All(vol.Coerce(int), vol.Range(min=-0, max=360)), - vol.Required( - CONF_MODULES_POWER, - default=self.config_entry.options[CONF_MODULES_POWER], - ): vol.All(vol.Coerce(int), vol.Range(min=1)), vol.Optional( CONF_DAMPING_MORNING, default=self.config_entry.options.get( - CONF_DAMPING_MORNING, 0.0 + CONF_DAMPING_MORNING, DEFAULT_DAMPING ), - ): vol.Coerce(float), + ): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=1, + step=0.01, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Coerce(float), + ), vol.Optional( CONF_DAMPING_EVENING, default=self.config_entry.options.get( - CONF_DAMPING_EVENING, 0.0 + CONF_DAMPING_EVENING, DEFAULT_DAMPING ), - ): vol.Coerce(float), + ): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=1, + step=0.01, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Coerce(float), + ), vol.Optional( CONF_INVERTER_SIZE, description={ @@ -149,8 +208,89 @@ class ForecastSolarOptionFlowHandler(OptionsFlowWithReload): CONF_INVERTER_SIZE ) }, - ): vol.Coerce(int), + ): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=1, + step=1, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Coerce(int), + ), } ), errors=errors, ) + + +class PlaneSubentryFlowHandler(ConfigSubentryFlow): + """Handle a subentry flow for adding/editing a plane.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle the user step to add a new plane.""" + entry = self._get_entry() + planes_count = len(entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE)) + if planes_count >= MAX_PLANES: + return self.async_abort(reason="max_planes") + if planes_count >= 1 and not entry.options.get(CONF_API_KEY): + return self.async_abort(reason="api_key_required") + + 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", + data={ + CONF_DECLINATION: user_input[CONF_DECLINATION], + CONF_AZIMUTH: user_input[CONF_AZIMUTH], + CONF_MODULES_POWER: user_input[CONF_MODULES_POWER], + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + PLANE_SCHEMA, + { + CONF_DECLINATION: DEFAULT_DECLINATION, + CONF_AZIMUTH: DEFAULT_AZIMUTH, + CONF_MODULES_POWER: DEFAULT_MODULES_POWER, + }, + ), + ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle reconfiguration of an existing plane.""" + subentry = self._get_reconfigure_subentry() + + if user_input is not None: + entry = self._get_entry() + if self._async_update( + entry, + subentry, + data={ + CONF_DECLINATION: user_input[CONF_DECLINATION], + 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", + ): + if not entry.update_listeners: + self.hass.config_entries.async_schedule_reload(entry.entry_id) + + return self.async_abort(reason="reconfigure_successful") + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + PLANE_SCHEMA, + { + CONF_DECLINATION: subentry.data[CONF_DECLINATION], + CONF_AZIMUTH: subentry.data[CONF_AZIMUTH], + CONF_MODULES_POWER: subentry.data[CONF_MODULES_POWER], + }, + ), + ) diff --git a/homeassistant/components/forecast_solar/const.py b/homeassistant/components/forecast_solar/const.py index ac80b64b869..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" @@ -14,3 +12,9 @@ CONF_DAMPING = "damping" CONF_DAMPING_MORNING = "damping_morning" CONF_DAMPING_EVENING = "damping_evening" CONF_INVERTER_SIZE = "inverter_size" +DEFAULT_DECLINATION = 25 +DEFAULT_AZIMUTH = 180 +DEFAULT_MODULES_POWER = 10000 +DEFAULT_DAMPING = 0.0 +MAX_PLANES = 4 +SUBENTRY_TYPE_PLANE = "plane" diff --git a/homeassistant/components/forecast_solar/coordinator.py b/homeassistant/components/forecast_solar/coordinator.py index efed954e490..514efd48045 100644 --- a/homeassistant/components/forecast_solar/coordinator.py +++ b/homeassistant/components/forecast_solar/coordinator.py @@ -1,10 +1,8 @@ """DataUpdateCoordinator for the Forecast.Solar integration.""" -from __future__ import annotations - from datetime import timedelta -from forecast_solar import Estimate, ForecastSolar, ForecastSolarConnectionError +from forecast_solar import Estimate, ForecastSolar, ForecastSolarConnectionError, Plane from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE @@ -19,8 +17,10 @@ from .const import ( CONF_DECLINATION, CONF_INVERTER_SIZE, CONF_MODULES_POWER, + DEFAULT_DAMPING, DOMAIN, LOGGER, + SUBENTRY_TYPE_PLANE, ) type ForecastSolarConfigEntry = ConfigEntry[ForecastSolarDataUpdateCoordinator] @@ -30,6 +30,7 @@ class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]): """The Forecast.Solar Data Update Coordinator.""" config_entry: ForecastSolarConfigEntry + forecast: ForecastSolar def __init__(self, hass: HomeAssistant, entry: ForecastSolarConfigEntry) -> None: """Initialize the Forecast.Solar coordinator.""" @@ -43,17 +44,34 @@ class ForecastSolarDataUpdateCoordinator(DataUpdateCoordinator[Estimate]): ) is not None and inverter_size > 0: inverter_size = inverter_size / 1000 + # Build the list of planes from subentries. + plane_subentries = entry.get_subentries_of_type(SUBENTRY_TYPE_PLANE) + + # The first plane subentry is the main plane + main_plane = plane_subentries[0] + + # Additional planes + planes: list[Plane] = [ + Plane( + declination=subentry.data[CONF_DECLINATION], + azimuth=(subentry.data[CONF_AZIMUTH] - 180), + kwp=(subentry.data[CONF_MODULES_POWER] / 1000), + ) + for subentry in plane_subentries[1:] + ] + self.forecast = ForecastSolar( api_key=api_key, session=async_get_clientsession(hass), latitude=entry.data[CONF_LATITUDE], longitude=entry.data[CONF_LONGITUDE], - declination=entry.options[CONF_DECLINATION], - azimuth=(entry.options[CONF_AZIMUTH] - 180), - kwp=(entry.options[CONF_MODULES_POWER] / 1000), - damping_morning=entry.options.get(CONF_DAMPING_MORNING, 0.0), - damping_evening=entry.options.get(CONF_DAMPING_EVENING, 0.0), + declination=main_plane.data[CONF_DECLINATION], + azimuth=(main_plane.data[CONF_AZIMUTH] - 180), + kwp=(main_plane.data[CONF_MODULES_POWER] / 1000), + damping_morning=entry.options.get(CONF_DAMPING_MORNING, DEFAULT_DAMPING), + damping_evening=entry.options.get(CONF_DAMPING_EVENING, DEFAULT_DAMPING), inverter=inverter_size, + planes=planes, ) # Free account have a resolution of 1 hour, using that as the default diff --git a/homeassistant/components/forecast_solar/diagnostics.py b/homeassistant/components/forecast_solar/diagnostics.py index cb33ac5dc5a..435de7c1f86 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 @@ -28,6 +26,13 @@ async def async_get_config_entry_diagnostics( "title": entry.title, "data": async_redact_data(entry.data, TO_REDACT), "options": async_redact_data(entry.options, TO_REDACT), + "subentries": [ + { + "data": dict(subentry.data), + "title": subentry.title, + } + for subentry in entry.subentries.values() + ], }, "data": { "energy_production_today": coordinator.data.energy_production_today, 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/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 b6cc406877f..6d0c3b45844 100644 --- a/homeassistant/components/forecast_solar/strings.json +++ b/homeassistant/components/forecast_solar/strings.json @@ -7,13 +7,43 @@ "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." } } }, + "config_subentries": { + "plane": { + "abort": { + "api_key_required": "An API key is required to add more than one plane. You can configure it in the integration options.", + "max_planes": "You can add a maximum of 4 planes.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "entry_type": "Plane", + "initiate_flow": { + "user": "Add plane" + }, + "step": { + "reconfigure": { + "data": { + "azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]", + "declination": "[%key:component::forecast_solar::config::step::user::data::declination%]", + "modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]" + }, + "description": "Edit the solar plane configuration." + }, + "user": { + "data": { + "azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]", + "declination": "[%key:component::forecast_solar::config::step::user::data::declination%]", + "modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]" + }, + "description": "Add a solar plane. Multiple planes are supported with a Forecast.Solar API subscription." + } + } + } + }, "entity": { "sensor": { "energy_current_hour": { @@ -51,20 +81,26 @@ } } }, + "exceptions": { + "api_key_required": { + "message": "An API key is required when more than one plane is configured" + }, + "no_plane": { + "message": "No plane configured, cannot set up Forecast.Solar" + } + }, "options": { "error": { + "api_key_required": "An API key is required to add more than one plane. You can configure it in the integration options.", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" }, "step": { "init": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]", - "azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]", "damping_evening": "Damping factor: adjusts the results in the evening", "damping_morning": "Damping factor: adjusts the results in the morning", - "declination": "[%key:component::forecast_solar::config::step::user::data::declination%]", - "inverter_size": "Inverter size (Watt)", - "modules_power": "[%key:component::forecast_solar::config::step::user::data::modules_power%]" + "inverter_size": "Inverter size (Watt)" }, "description": "These values allow tweaking the Forecast.Solar result. 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..6e7a4547aa3 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 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..d29f15f8074 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 diff --git a/homeassistant/components/fortios/__init__.py b/homeassistant/components/fortios/__init__.py index 873d6c00c65..873e363643f 100644 --- a/homeassistant/components/fortios/__init__.py +++ b/homeassistant/components/fortios/__init__.py @@ -1 +1 @@ -"""Fortinet FortiOS components.""" +"""Fortinet FortiOS integration.""" diff --git a/homeassistant/components/fortios/device_tracker.py b/homeassistant/components/fortios/device_tracker.py index 4360dd031c7..71f6cff421d 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -1,10 +1,8 @@ """Support to use FortiOS device like FortiGate as device tracker. -This component is part of the device_tracker platform. +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/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..d4003b36d9c 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 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/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py index 0952af2b415..83879e14115 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 diff --git a/homeassistant/components/freebox/button.py b/homeassistant/components/freebox/button.py index 21a7b1c9990..7bcb3b7f788 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 diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py index af816b31024..b3ac71e80ed 100644 --- a/homeassistant/components/freebox/camera.py +++ b/homeassistant/components/freebox/camera.py @@ -1,7 +1,5 @@ """Support for Freebox cameras.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index 7ca26f7f34e..a7e0f4afc72 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -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=hass-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..dbfdcfac83d 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 diff --git a/homeassistant/components/freebox/entity.py b/homeassistant/components/freebox/entity.py index e29ffb071e9..9272d35e6ad 100644 --- a/homeassistant/components/freebox/entity.py +++ b/homeassistant/components/freebox/entity.py @@ -1,7 +1,5 @@ """Support for Freebox base features.""" -from __future__ import annotations - import logging from typing import Any 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..a654552155d 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 diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 53314549f57..8ea209c7bb6 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 diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py index 9506a87b5fa..2dee05a8ba2 100644 --- a/homeassistant/components/freebox/switch.py +++ b/homeassistant/components/freebox/switch.py @@ -1,7 +1,5 @@ """Support for Freebox Delta, Revolution and Mini 4K.""" -from __future__ import annotations - import logging from typing import Any 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 e3d366ff03d..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 @@ -30,22 +28,31 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + async def _validate_input(self, username: str, password: str) -> str | None: + """Validate credentials, returning an error key or None on success.""" + client = FreshrClient(session=async_get_clientsession(self.hass)) + try: + await client.login(username, password) + except LoginError: + return "invalid_auth" + except ClientError: + return "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + return "unknown" + return 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: - client = FreshrClient(session=async_get_clientsession(self.hass)) - try: - await client.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) - except LoginError: - errors["base"] = "invalid_auth" - except ClientError: - errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + error = await self._validate_input( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if error: + errors["base"] = error else: await self.async_set_unique_id(user_input[CONF_USERNAME].lower()) self._abort_if_unique_id_configured() @@ -58,6 +65,34 @@ class FreshrFlowHandler(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.""" + reconfigure_entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + error = await self._validate_input( + reconfigure_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if error: + errors["base"] = error + else: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}), + description_placeholders={ + CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME] + }, + errors=errors, + ) + async def async_step_reauth( self, _user_input: Mapping[str, Any] ) -> ConfigFlowResult: @@ -72,18 +107,11 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN): reauth_entry = self._get_reauth_entry() if user_input is not None: - client = FreshrClient(session=async_get_clientsession(self.hass)) - try: - await client.login( - reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] - ) - except LoginError: - errors["base"] = "invalid_auth" - except ClientError: - errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + error = await self._validate_input( + reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + if error: + errors["base"] = error else: return self.async_update_reload_and_abort( reauth_entry, 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 new file mode 100644 index 00000000000..dee204b12dd --- /dev/null +++ b/homeassistant/components/freshr/diagnostics.py @@ -0,0 +1,32 @@ +"""Diagnostics support for Fresh-r.""" + +import dataclasses +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from .coordinator import FreshrConfigEntry + +TO_REDACT = {CONF_PASSWORD} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: FreshrConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + runtime_data = entry.runtime_data + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "devices": [ + dataclasses.asdict(device) for device in runtime_data.devices.data.values() + ], + "readings": { + device_id: dataclasses.asdict(coordinator.data) + if coordinator.data is not None + else None + for device_id, coordinator in runtime_data.readings.items() + }, + } 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/quality_scale.yaml b/homeassistant/components/freshr/quality_scale.yaml index c8c60a6330c..1d3ae24ae3e 100644 --- a/homeassistant/components/freshr/quality_scale.yaml +++ b/homeassistant/components/freshr/quality_scale.yaml @@ -41,7 +41,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Integration connects to a cloud service; no local network discovery is possible. @@ -62,7 +62,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow. 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/freshr/strings.json b/homeassistant/components/freshr/strings.json index ee833d999c9..f7627054914 100644 --- a/homeassistant/components/freshr/strings.json +++ b/homeassistant/components/freshr/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%]", @@ -19,6 +20,15 @@ }, "description": "Re-enter the password for your Fresh-r account `{username}`." }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "password": "[%key:component::freshr::config::step::user::data_description::password%]" + }, + "description": "Update the password for your Fresh-r account `{username}`." + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", 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 af5c1b0e869..fb0dfcfe938 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 @@ -14,11 +12,12 @@ from homeassistant.components.button import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles +from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData from .entity import FritzDeviceBase from .helpers import _is_tracked @@ -44,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", @@ -63,10 +63,65 @@ BUTTONS: Final = [ translation_key="cleanup", entity_category=EntityCategory.CONFIG, press_action=lambda avm_wrapper: avm_wrapper.async_trigger_cleanup(), + entity_registry_enabled_default=False, ), ] +def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None: + """Repair issue for cleanup button.""" + entity_registry = er.async_get(hass) + + if ( + ( + entity_button := entity_registry.async_get_entity_id( + "button", DOMAIN, f"{avm_wrapper.unique_id}-cleanup" + ) + ) + and (entity_entry := entity_registry.async_get(entity_button)) + and not entity_entry.disabled + ): + # Deprecate the 'cleanup' button: create a Repairs issue for users + ir.async_create_issue( + hass, + domain=DOMAIN, + issue_id="deprecated_cleanup_button", + is_fixable=False, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_cleanup_button", + translation_placeholders={"removal_version": "2026.11.0"}, + breaks_in_ha_version="2026.11.0", + ) + + +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, @@ -82,6 +137,8 @@ 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] @@ -100,6 +157,9 @@ async def async_setup_entry( ) ) + repair_issue_cleanup(hass, avm_wrapper) + repair_issue_firmware_update(hass, avm_wrapper) + class FritzButton(ButtonEntity): """Defines a Fritz!Box base button.""" @@ -126,6 +186,18 @@ class FritzButton(ButtonEntity): async def async_press(self) -> None: """Triggers Fritz!Box service.""" + if self.entity_description.key == "cleanup": + _LOGGER.warning( + "The 'cleanup' button is deprecated and will be removed in Home Assistant Core 2026.11.0. " + "Please update your automations and dashboards to remove any usage of this button. " + "The action is now performed automatically at each data refresh", + ) + 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) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index cd8dda57402..953f9958766 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 @@ -198,7 +196,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 diff --git a/homeassistant/components/fritz/const.py b/homeassistant/components/fritz/const.py index 604d3f94bf9..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, @@ -80,6 +78,5 @@ FRITZ_EXCEPTIONS = ( FRITZ_AUTH_EXCEPTIONS = (FritzAuthorizationError, FritzSecurityError) -WIFI_STANDARD = {1: "2.4Ghz", 2: "5Ghz", 3: "5Ghz", 4: "Guest"} CONNECTION_TYPE_LAN = "LAN" diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index 0cc359b318a..9e7e1d811be 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 @@ -332,7 +330,10 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): translation_placeholders={"error": str(ex)}, ) from ex - _LOGGER.debug("enity_data: %s", entity_data) + _LOGGER.debug("entity_data: %s", entity_data) + + await self.async_trigger_cleanup() + return entity_data @property @@ -376,6 +377,8 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Return device Mac address.""" if not self._unique_id: raise ClassSetupMissing + # Unique ID is the serial number of the device + # which is the MAC of the device without the colons return dr.format_mac(self._unique_id) @property @@ -448,10 +451,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"], @@ -687,7 +693,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 diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index be8cde57534..22248c8e32b 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 diff --git a/homeassistant/components/fritz/diagnostics.py b/homeassistant/components/fritz/diagnostics.py index bfeb22e2721..43ed80f6396 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 diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py index ade76993972..86130910ba9 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 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 fd590055825..f2b202a9717 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 @@ -10,9 +8,11 @@ from requests.exceptions import RequestException from homeassistant.components.image import ImageEntity from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util, slugify +from .const import DOMAIN, Platform from .coordinator import AvmWrapper, FritzConfigEntry from .entity import FritzBoxBaseEntity @@ -22,6 +22,32 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +async def _migrate_to_new_unique_id( + hass: HomeAssistant, avm_wrapper: AvmWrapper, ssid: str +) -> None: + """Migrate old unique id to new unique id.""" + + old_unique_id = slugify(f"{avm_wrapper.unique_id}-{ssid}-qr-code") + new_unique_id = f"{avm_wrapper.unique_id}-guest_wifi_qr_code" + + entity_registry = er.async_get(hass) + entity_id = entity_registry.async_get_entity_id( + Platform.IMAGE, + DOMAIN, + old_unique_id, + ) + + if entity_id is None: + return + + entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id) + _LOGGER.debug( + "Migrating guest Wi-Fi image unique_id from [%s] to [%s]", + old_unique_id, + new_unique_id, + ) + + async def async_setup_entry( hass: HomeAssistant, entry: FritzConfigEntry, @@ -34,6 +60,8 @@ async def async_setup_entry( avm_wrapper.fritz_guest_wifi.get_info ) + await _migrate_to_new_unique_id(hass, avm_wrapper, guest_wifi_info["NewSSID"]) + async_add_entities( [ FritzGuestWifiQRImage( @@ -60,7 +88,7 @@ class FritzGuestWifiQRImage(FritzBoxBaseEntity, ImageEntity): ) -> None: """Initialize the image entity.""" self._attr_name = ssid - self._attr_unique_id = slugify(f"{avm_wrapper.unique_id}-{ssid}-qr-code") + self._attr_unique_id = f"{avm_wrapper.unique_id}-guest_wifi_qr_code" self._current_qr_bytes: bytes | None = None super().__init__(avm_wrapper, device_friendly_name) ImageEntity.__init__(self, hass) diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 8688eddbdab..a23c1697456 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -8,8 +8,8 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "quality_scale": "silver", - "requirements": ["fritzconnection[qr]==1.15.1", "xmltodict==1.0.2"], + "quality_scale": "gold", + "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..35d903ba0cd 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 diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 8818bc04cdb..3eec68bea5f 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -56,9 +56,7 @@ rules: repair-issues: status: exempt comment: no known use cases for repair issues or flows, yet - stale-devices: - status: todo - comment: automate the current cleanup process and deprecate the corresponding button + stale-devices: done # Platinum async-dependency: 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 c2aa92818b1..67326ae4ea2 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" }, @@ -169,6 +166,18 @@ "switch": { "internet_access": { "name": "Internet access" + }, + "wi_fi_guest": { + "name": "Guest" + }, + "wi_fi_main_2_4ghz": { + "name": "Main 2.4 GHz" + }, + "wi_fi_main_5ghz": { + "name": "Main 5 GHz" + }, + "wi_fi_main_5ghz_high_6ghz": { + "name": "Main 5 GHz High / 6 GHz" } } }, @@ -195,6 +204,16 @@ "message": "Error while updating the data: {error}" } }, + "issues": { + "deprecated_cleanup_button": { + "description": "The 'Cleanup' button is deprecated and will be removed in Home Assistant Core {removal_version}. Please update your automations and dashboards to remove any usage of this button. The action is now performed automatically at each data refresh.", + "title": "'Cleanup' button is deprecated" + }, + "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": { "step": { "init": { diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 61255e27a4d..5b3fa1357d6 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 @@ -9,6 +7,7 @@ from homeassistant.components.network import async_get_source_ip from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -22,8 +21,8 @@ from .const import ( SWITCH_TYPE_PORTFORWARD, SWITCH_TYPE_PROFILE, SWITCH_TYPE_WIFINETWORK, - WIFI_STANDARD, MeshRoles, + Platform, ) from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData from .entity import FritzBoxBaseEntity @@ -35,6 +34,101 @@ _LOGGER = logging.getLogger(__name__) # Set a sane value to avoid too many updates PARALLEL_UPDATES = 5 +WIFI_STANDARD = {1: "2.4Ghz", 2: "5Ghz", 3: "5Ghz", 4: "Guest"} + +WIFI_BAND = { + 0: {"band": "2.4Ghz"}, + 1: {"band": "5Ghz"}, + 3: {"band": "5Ghz High / 6Ghz"}, +} + + +def _wifi_naming( + network_info: dict[str, Any], wifi_index: int, wifi_count: int +) -> str | None: + """Return a friendly name for a Wi-Fi network.""" + + if wifi_index == 2 and wifi_count == 4: + # In case of 4 Wi-Fi networks, the 2nd one is used for internal communication + # between mesh devices and should not be named like the others to avoid confusion + return None + + if (wifi_index + 1) == wifi_count: + # Last Wi-Fi network in the guest network, both bands available + return "Guest" + + # Cast to correct type for type checker + if (result := WIFI_BAND.get(wifi_index)) is not None: + return f"Main {result['band']}" + + return None + + +async def _get_wifi_networks_list(avm_wrapper: AvmWrapper) -> dict[int, dict[str, Any]]: + """Get a list of wifi networks with friendly names.""" + wifi_count = len( + [ + s + for s in avm_wrapper.connection.services + if s.startswith("WLANConfiguration") + ] + ) + _LOGGER.debug("WiFi networks count: %s", wifi_count) + networks: dict[int, dict[str, Any]] = {} + for i in range(1, wifi_count + 1): + network_info = await avm_wrapper.async_get_wlan_configuration(i) + if (switch_name := _wifi_naming(network_info, i - 1, wifi_count)) is None: + continue + networks[i] = network_info + networks[i]["switch_name"] = switch_name + + _LOGGER.debug("WiFi networks list: %s", networks) + return networks + + +async def _migrate_to_new_unique_id( + hass: HomeAssistant, avm_wrapper: AvmWrapper +) -> None: + """Migrate old unique ids to new unique ids.""" + + _LOGGER.debug("Migrating Wi-Fi switches") + entity_registry = er.async_get(hass) + + networks = await _get_wifi_networks_list(avm_wrapper) + for index, network in networks.items(): + description = f"Wi-Fi {network['NewSSID']}" + if ( + len( + [ + j + for j, n in networks.items() + if slugify(n["NewSSID"]) == slugify(network["NewSSID"]) + ] + ) + > 1 + ): + description += f" ({WIFI_STANDARD[index]})" + + old_unique_id = f"{avm_wrapper.unique_id}-{slugify(description)}" + new_unique_id = f"{avm_wrapper.unique_id}-wi_fi_{slugify(_wifi_naming(network, index - 1, len(networks)))}" + + entity_id = entity_registry.async_get_entity_id( + Platform.SWITCH, DOMAIN, old_unique_id + ) + + if entity_id is not None: + entity_registry.async_update_entity( + entity_id, + new_unique_id=new_unique_id, + ) + _LOGGER.debug( + "Migrating Wi-FI switch unique_id from [%s] to [%s]", + old_unique_id, + new_unique_id, + ) + + _LOGGER.debug("Migration completed") + async def _async_deflection_entities_list( avm_wrapper: AvmWrapper, device_friendly_name: str @@ -125,35 +219,7 @@ async def _async_wifi_entities_list( # # https://avm.de/fileadmin/user_upload/Global/Service/Schnittstellen/wlanconfigSCPD.pdf # - wifi_count = len( - [ - s - for s in avm_wrapper.connection.services - if s.startswith("WLANConfiguration") - ] - ) - _LOGGER.debug("WiFi networks count: %s", wifi_count) - networks: dict[int, dict[str, Any]] = {} - for i in range(1, wifi_count + 1): - network_info = await avm_wrapper.async_get_wlan_configuration(i) - # Devices with 4 WLAN services, use the 2nd for internal communications - if not (wifi_count == 4 and i == 2): - networks[i] = network_info - for i, network in networks.copy().items(): - networks[i]["switch_name"] = network["NewSSID"] - if ( - len( - [ - j - for j, n in networks.items() - if slugify(n["NewSSID"]) == slugify(network["NewSSID"]) - ] - ) - > 1 - ): - networks[i]["switch_name"] += f" ({WIFI_STANDARD[i]})" - - _LOGGER.debug("WiFi networks list: %s", networks) + networks = await _get_wifi_networks_list(avm_wrapper) return [ FritzBoxWifiSwitch(avm_wrapper, device_friendly_name, index, data) for index, data in networks.items() @@ -225,6 +291,8 @@ async def async_setup_entry( local_ip = await async_get_source_ip(avm_wrapper.hass, target_ip=avm_wrapper.host) + await _migrate_to_new_unique_id(hass, avm_wrapper) + entities_list = await async_all_entities_list( avm_wrapper, entry.title, @@ -554,8 +622,11 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch): ) self._network_num = network_num + description = f"Wi-Fi {network_data['switch_name']}" + self._attr_translation_key = slugify(description) + switch_info = SwitchInfo( - description=f"Wi-Fi {network_data['switch_name']}", + description=description, friendly_name=device_friendly_name, icon="mdi:wifi", type=SWITCH_TYPE_WIFINETWORK, 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..1dfb7942f9d 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 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..ee58050cc13 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 diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index 66917298922..dd1525b02df 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 ( 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/switch.py b/homeassistant/components/fritzbox/switch.py index 9ddc48b55d3..0cfa7733bb8 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 diff --git a/homeassistant/components/fritzbox_callmonitor/base.py b/homeassistant/components/fritzbox_callmonitor/base.py index 3c8714624e7..2b0945c8df3 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 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..679bdb24732 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 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..564fc5f3044 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 diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 6531f80ddaf..3479ce1113c 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. @@ -599,6 +601,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 4c8256a82e6..a2199ee5f4d 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.2"] + "requirements": ["home-assistant-frontend==20260429.3"] } 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..3c6c2480926 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) @@ -144,7 +137,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 +145,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() @@ -201,7 +194,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 +208,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 baa684786bf..fb00e846d1b 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.2.7"], + "loggers": ["afsapi"], + "requirements": ["afsapi==1.0.0"], "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..66d4654f5b2 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( @@ -131,15 +200,38 @@ class AFSAPIDevice(MediaPlayerEntity): } 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 +258,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 +297,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_state()) == 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 +382,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 +391,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 +423,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: 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..e9cd16ba34b 100644 --- a/homeassistant/components/fujitsu_fglair/__init__.py +++ b/homeassistant/components/fujitsu_fglair/__init__.py @@ -1,7 +1,5 @@ """The Fujitsu HVAC (based on Ayla IOT) integration.""" -from __future__ import annotations - from contextlib import suppress from ayla_iot_unofficial import new_ayla_api 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..7dc7298260f 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 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..3433c0f321a 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 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..54cee65a556 --- /dev/null +++ b/homeassistant/components/fumis/icons.json @@ -0,0 +1,112 @@ +{ + "entity": { + "button": { + "sync_clock": { + "default": "mdi:clock-sync" + } + }, + "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..b0a70ace731 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 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..80802c5d6cb 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 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 f0e50ad82a1..87eddbf8cc3 100644 --- a/homeassistant/components/garage_door/strings.json +++ b/homeassistant/components/garage_door/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted garage doors.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted garage doors to trigger on.", - "trigger_behavior_name": "Behavior" + "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_closed": { "description": "Tests if one or more garage doors are closed.", "fields": { "behavior": { - "description": "[%key:component::garage_door::common::condition_behavior_description%]", "name": "[%key:component::garage_door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::garage_door::common::condition_for_name%]" } }, "name": "Garage door is closed" @@ -20,36 +22,25 @@ "description": "Tests if one or more garage doors are open.", "fields": { "behavior": { - "description": "[%key:component::garage_door::common::condition_behavior_description%]", "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": { "description": "Triggers after one or more garage doors close.", "fields": { "behavior": { - "description": "[%key:component::garage_door::common::trigger_behavior_description%]", "name": "[%key:component::garage_door::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::garage_door::common::trigger_for_name%]" } }, "name": "Garage door closed" @@ -58,8 +49,10 @@ "description": "Triggers after one or more garage doors open.", "fields": { "behavior": { - "description": "[%key:component::garage_door::common::trigger_behavior_description%]", "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..2e6099413ea 100644 --- a/homeassistant/components/garage_door/triggers.yaml +++ b/homeassistant/components/garage_door/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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..9650042ffb8 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -1,8 +1,5 @@ """The Gardena Bluetooth integration.""" -from __future__ import annotations - -import asyncio import logging from bleak.backends.device import BLEDevice @@ -13,7 +10,8 @@ from gardena_bluetooth.exceptions import ( CharacteristicNotFound, CommunicationFailure, ) -from gardena_bluetooth.parse import CharacteristicTime +from gardena_bluetooth.parse import CharacteristicTime, ProductType +from gardena_bluetooth.scan import async_get_manufacturer_data from homeassistant.components import bluetooth from homeassistant.const import CONF_ADDRESS, Platform @@ -29,7 +27,6 @@ from .coordinator import ( GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator, ) -from .util import async_get_product_type PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -38,6 +35,7 @@ PLATFORMS: list[Platform] = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.TEXT, Platform.VALVE, ] LOGGER = logging.getLogger(__name__) @@ -75,11 +73,10 @@ 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 + mfg_data = await async_get_manufacturer_data({address}) + product_type = mfg_data[address].product_type + if product_type == ProductType.UNKNOWN: + raise ConfigEntryNotReady("Unable to find product type") client = Client(get_connection(hass, address), product_type) try: 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..c7a1a1adbfc 100644 --- a/homeassistant/components/gardena_bluetooth/config_flow.py +++ b/homeassistant/components/gardena_bluetooth/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Gardena Bluetooth integration.""" -from __future__ import annotations - import logging from typing import Any @@ -9,6 +7,7 @@ from gardena_bluetooth.client import Client from gardena_bluetooth.const import PRODUCT_NAMES, DeviceInformation, ScanService from gardena_bluetooth.exceptions import CharacteristicNotFound, CommunicationFailure from gardena_bluetooth.parse import ManufacturerData, ProductType +from gardena_bluetooth.scan import async_get_manufacturer_data import voluptuous as vol from homeassistant.components.bluetooth import ( @@ -24,41 +23,27 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +_SUPPORTED_PRODUCT_TYPES = { + ProductType.PUMP, + ProductType.VALVE, + ProductType.WATER_COMPUTER, + ProductType.AUTOMATS, + ProductType.PRESSURE_TANKS, + ProductType.AQUA_CONTOURS, +} + 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)): + if discovery_info.manufacturer_data.get(ManufacturerData.company) is None: _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") - - class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Gardena Bluetooth.""" @@ -90,11 +75,13 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the bluetooth discovery step.""" _LOGGER.debug("Discovered device: %s", discovery_info) - if not _is_supported(discovery_info): + data = await async_get_manufacturer_data({discovery_info.address}) + product_type = data[discovery_info.address].product_type + 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() @@ -131,12 +118,21 @@ class GardenaBluetoothConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_confirm() current_addresses = self._async_current_ids(include_ignore=False) + candidates = set() 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 + candidates.add(address) - self.devices[address] = _get_name(discovery_info) + data = await async_get_manufacturer_data(candidates) + for address, mfg_data in data.items(): + if mfg_data.product_type not in _SUPPORTED_PRODUCT_TYPES: + continue + self.devices[address] = PRODUCT_NAMES[mfg_data.product_type] + + # Keep selection sorted by address to ensure stable tests + self.devices = dict(sorted(self.devices.items(), key=lambda x: x[0])) 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..eae73ac740d 100644 --- a/homeassistant/components/gardena_bluetooth/coordinator.py +++ b/homeassistant/components/gardena_bluetooth/coordinator.py @@ -1,7 +1,5 @@ """Provides the DataUpdateCoordinator.""" -from __future__ import annotations - from datetime import timedelta import logging 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 03c342f7478..c484863d2e0 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -1,10 +1,14 @@ """Support for number entities.""" -from __future__ import annotations - from dataclasses import dataclass, field -from gardena_bluetooth.const import DeviceConfiguration, Sensor, Spray, Valve +from gardena_bluetooth.const import ( + AquaContourWatering, + DeviceConfiguration, + Sensor, + Spray, + Valve, +) from gardena_bluetooth.parse import ( Characteristic, CharacteristicInt, @@ -58,6 +62,18 @@ DESCRIPTIONS = ( char=Valve.manual_watering_time, device_class=NumberDeviceClass.DURATION, ), + GardenaBluetoothNumberEntityDescription( + key=AquaContourWatering.manual_watering_time.unique_id, + translation_key="manual_watering_time", + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + native_min_value=0.0, + native_max_value=24 * 60 * 60, + native_step=60, + entity_category=EntityCategory.CONFIG, + char=AquaContourWatering.manual_watering_time, + device_class=NumberDeviceClass.DURATION, + ), GardenaBluetoothNumberEntityDescription( key=Valve.remaining_open_time.unique_id, translation_key="remaining_open_time", @@ -113,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( @@ -124,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..585f6a94c1a 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,18 +259,21 @@ 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() @@ -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 8c8815631eb..b7a848d0680 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -50,6 +50,9 @@ "remaining_open_time": { "name": "Remaining open time" }, + "remaining_watering_time": { + "name": "Remaining watering time" + }, "seasonal_adjust": { "name": "Seasonal adjust" }, @@ -131,6 +134,9 @@ "remaining_open_timestamp": { "name": "Valve closing" }, + "remaining_watering_timestamp": { + "name": "Watering finished" + }, "sensor_battery_level": { "name": "Sensor battery" }, @@ -151,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 134e9bb108f..d40aae3a4ae 100644 --- a/homeassistant/components/gate/strings.json +++ b/homeassistant/components/gate/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted gates.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted gates to trigger on.", - "trigger_behavior_name": "Behavior" + "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_closed": { "description": "Tests if one or more gates are closed.", "fields": { "behavior": { - "description": "[%key:component::gate::common::condition_behavior_description%]", "name": "[%key:component::gate::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::gate::common::condition_for_name%]" } }, "name": "Gate is closed" @@ -20,36 +22,25 @@ "description": "Tests if one or more gates are open.", "fields": { "behavior": { - "description": "[%key:component::gate::common::condition_behavior_description%]", "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": { "description": "Triggers after one or more gates close.", "fields": { "behavior": { - "description": "[%key:component::gate::common::trigger_behavior_description%]", "name": "[%key:component::gate::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::gate::common::trigger_for_name%]" } }, "name": "Gate closed" @@ -58,8 +49,10 @@ "description": "Triggers after one or more gates open.", "fields": { "behavior": { - "description": "[%key:component::gate::common::trigger_behavior_description%]", "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..ed91d2f4246 100644 --- a/homeassistant/components/gate/triggers.yaml +++ b/homeassistant/components/gate/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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..47675ae173b 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 diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 4e04b2eae68..44d95751c32 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 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..d5ed450df1d 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==16.0.1", "Pillow==12.2.0"] } diff --git a/homeassistant/components/generic_hygrostat/config_flow.py b/homeassistant/components/generic_hygrostat/config_flow.py index 88cf12d741b..6630e7866cb 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 diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 7746346d010..a67d37e1753 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 diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 10b24ec17ca..6a3dce6889f 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -1,13 +1,12 @@ """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 @@ -51,6 +50,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, @@ -478,6 +478,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 +532,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 +555,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 +590,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 +612,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 +627,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 +669,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 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..3b04364d1c9 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 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..6574839fda0 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 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/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/oauth.py b/homeassistant/components/geocaching/oauth.py index c872f9a7522..d72a197a3a7 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 ( diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py index daf64546f47..75eade05cf1 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 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/sensor.py b/homeassistant/components/ghost/sensor.py index 9fd3ea977c6..d19a134062b 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 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..95e96219766 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 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/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..a99ebe22dce 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, @@ -153,7 +172,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 +188,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 +209,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/__init__.py b/homeassistant/components/glances/__init__.py index d7b645d9e11..44460ed1928 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -1,4 +1,4 @@ -"""The Glances component.""" +"""The Glances integration.""" 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/const.py b/homeassistant/components/glances/const.py index f0477a30463..6831ccb9e3b 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -1,4 +1,4 @@ -"""Constants for Glances component.""" +"""Constants for Glances integration.""" from datetime import timedelta import sys 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 67f57ee0fbf..f86befe762d 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -1,6 +1,4 @@ -"""Support gathering system information of hosts which are running glances.""" - -from __future__ import annotations +"""Support gathering system information of hosts which are running Glances.""" from dataclasses import dataclass @@ -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..3d4d6c67d74 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__) @@ -175,6 +173,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 +306,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 +352,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: @@ -398,18 +397,19 @@ 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 # 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/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..7686a8f8d12 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 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/config_flow.py b/homeassistant/components/goodwe/config_flow.py index 5faa2b86768..07a1b94d7a4 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 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/sensor.py b/homeassistant/components/goodwe/sensor.py index 110e26ae5e2..3eae0e64498 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,6 +39,9 @@ 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 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..10735392634 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 diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index a998ea70d00..418cdc68964 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 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/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/strings.json b/homeassistant/components/google/strings.json index 91fd097ef0d..647c108274d 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -111,7 +111,7 @@ "name": "Add event" }, "create_event": { - "description": "Adds a new calendar event.", + "description": "Adds a new event to a Google calendar.", "fields": { "description": { "description": "[%key:component::google::services::add_event::fields::description::description%]", @@ -146,7 +146,7 @@ "name": "Summary" } }, - "name": "Create event" + "name": "Create event in Google Calendar" } } } diff --git a/homeassistant/components/google_air_quality/config_flow.py b/homeassistant/components/google_air_quality/config_flow.py index b0f1cd41826..b3a1f72655a 100644 --- a/homeassistant/components/google_air_quality/config_flow.py +++ b/homeassistant/components/google_air_quality/config_flow.py @@ -1,7 +1,5 @@ """Config flow for the Google Air Quality integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index cfcada03a5c..28acfd8d793 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=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging diff --git a/homeassistant/components/google_assistant/button.py b/homeassistant/components/google_assistant/button.py index 00d809a851c..5dc6a8d2c6a 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=hass-use-runtime-data yaml_config: ConfigType = hass.data[DOMAIN][DATA_CONFIG] google_config = config_entry.runtime_data 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..a623ce56f6a 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 diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 8d317292ab6..961c9d3d345 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 diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 7fbe4bab5a9..bcfa2896b37 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 diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 5ae72b7a41a..219e3047dc0 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 @@ -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, @@ -1236,14 +1236,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, 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..7d1b457fdc2 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 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..f6fd4bce7a0 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 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..34a35cf8363 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 diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py index a566f57f7e0..1d029304895 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 diff --git a/homeassistant/components/google_drive/api.py b/homeassistant/components/google_drive/api.py index 909b85bb713..d780cce7f0d 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 diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py index 40ebc7c7cec..bd2e81ee739 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 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..e90c067204e 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 diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 0572c63085a..558485ea40f 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 diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index d804073bfb4..102c403b4db 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -1,7 +1,5 @@ """Conversation support for the Google Generative AI Conversation integration.""" -from __future__ import annotations - from typing import Literal from homeassistant.components import conversation 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..4b0b9a45808 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 @@ -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)} @@ -623,7 +635,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) ] diff --git a/homeassistant/components/google_generative_ai_conversation/helpers.py b/homeassistant/components/google_generative_ai_conversation/helpers.py index 3d053aa9f1a..7942335a069 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}") @@ -65,9 +63,9 @@ def _parse_audio_mime_type(mime_type: str) -> dict[str, int]: 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..efd5f4f72b8 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 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..8c66287c523 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=hass-use-runtime-data hass.data[DOMAIN][DATA_HASS_CONFIG], ) ) diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py index 3e455f645ad..8162a4d74d0 100644 --- a/homeassistant/components/google_mail/api.py +++ b/homeassistant/components/google_mail/api.py @@ -2,8 +2,7 @@ from functools import partial -from aiohttp.client_exceptions import ClientError, ClientResponseError -from google.auth.exceptions import RefreshError +from aiohttp.client_exceptions import ClientError from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build @@ -14,6 +13,8 @@ from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, ) from homeassistant.helpers import config_entry_oauth2_flow @@ -37,24 +38,26 @@ class AsyncConfigEntryAuth: async def check_and_refresh_token(self) -> str: """Check the token.""" + setup_in_progress = ( + self.oauth_session.config_entry.state is ConfigEntryState.SETUP_IN_PROGRESS + ) + try: await self.oauth_session.async_ensure_token_valid() - except (RefreshError, ClientResponseError, ClientError) as ex: - if ( - self.oauth_session.config_entry.state - is ConfigEntryState.SETUP_IN_PROGRESS - ): - if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500: - raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauth required" - ) from ex + except OAuth2TokenRequestReauthError as ex: + if setup_in_progress: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from ex + self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass) + raise + except OAuth2TokenRequestError as ex: + if setup_in_progress: + raise ConfigEntryNotReady from ex + raise + except ClientError as ex: + if setup_in_progress: raise ConfigEntryNotReady from ex - if isinstance(ex, RefreshError) or ( - hasattr(ex, "status") and ex.status == 400 - ): - self.oauth_session.config_entry.async_start_reauth( - self.oauth_session.hass - ) raise HomeAssistantError(ex) from ex return self.access_token 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..81f89694549 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 diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index aaedd38cc7e..606aba73f88 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 diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index bb041da4a63..5295dd6690e 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -94,7 +94,7 @@ "name": "Filename" } }, - "name": "Upload media" + "name": "Upload media to Google Photos" } } } 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_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index 7dfe6bc3612..ae5f81c6fc0 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -68,7 +68,7 @@ "name": "Worksheet" } }, - "name": "Append to sheet" + "name": "Append data to Google sheet" }, "get_sheet": { "description": "Gets data from a worksheet in Google Sheets.", @@ -86,7 +86,7 @@ "name": "[%key:component::google_sheets::services::append_sheet::fields::worksheet::name%]" } }, - "name": "Get data from sheet" + "name": "Get data from Google sheet" } } } 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/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/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index d27c0202f2d..cfe9a39d085 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 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..0dff44a1c6c 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, @@ -81,11 +83,16 @@ 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. @@ -106,7 +113,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 +123,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 +171,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,6 +224,11 @@ 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, @@ -176,16 +240,35 @@ class LocationSubentryFlowHandler(ConfigSubentryFlow): 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 +279,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..61fc9144f1c 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 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..c822e520555 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, 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..5c8e0371944 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, diff --git a/homeassistant/components/govee_ble/config_flow.py b/homeassistant/components/govee_ble/config_flow.py index d48fffdd633..8bc2ee14927 100644 --- a/homeassistant/components/govee_ble/config_flow.py +++ b/homeassistant/components/govee_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for govee ble integration.""" -from __future__ import annotations - from typing import Any from govee_ble import GoveeBluetoothDeviceData as DeviceData diff --git a/homeassistant/components/govee_ble/coordinator.py b/homeassistant/components/govee_ble/coordinator.py index 4408b7f3199..011a89e565b 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 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..a440d7afb38 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 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..0efb15aee92 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 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..d1d3ec2a091 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 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/quality_scale.yaml b/homeassistant/components/green_planet_energy/quality_scale.yaml index 8ac2acdeed8..acebf273667 100644 --- a/homeassistant/components/green_planet_energy/quality_scale.yaml +++ b/homeassistant/components/green_planet_energy/quality_scale.yaml @@ -5,8 +5,7 @@ rules: comment: The integration registers no actions. appropriate-polling: done brands: done - common-modules: - status: done + common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done @@ -47,8 +46,7 @@ rules: test-coverage: done # Gold - devices: - status: done + devices: done diagnostics: todo discovery-update-info: status: exempt diff --git a/homeassistant/components/green_planet_energy/sensor.py b/homeassistant/components/green_planet_energy/sensor.py index dac92b8c4e1..9c79a8dc9cd 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..8f2d833e850 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 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..cd6ee5db4da 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -1,7 +1,5 @@ """Platform allowing several sensors to be grouped into one sensor to provide numeric combinations.""" -from __future__ import annotations - from collections.abc import Callable from datetime import datetime import logging 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..6af7198f1b7 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 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/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index bec7e583c26..1914dc21512 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -8,7 +8,7 @@ import growattServer import requests import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -64,6 +64,16 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): menu_options=["password_auth", "token_auth"], ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self._async_step_credentials( + step_id="reconfigure", + entry=self._get_reconfigure_entry(), + user_input=user_input, + ) + async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult: """Handle reauth.""" return await self.async_step_reauth_confirm() @@ -72,11 +82,23 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reauth confirmation.""" + return await self._async_step_credentials( + step_id="reauth_confirm", + entry=self._get_reauth_entry(), + user_input=user_input, + ) + + async def _async_step_credentials( + self, + step_id: str, + entry: ConfigEntry, + user_input: dict[str, Any] | None, + ) -> ConfigFlowResult: + """Handle credential update for both reauth and reconfigure.""" errors: dict[str, str] = {} - reauth_entry = self._get_reauth_entry() if user_input is not None: - auth_type = reauth_entry.data.get(CONF_AUTH_TYPE) + auth_type = entry.data.get(CONF_AUTH_TYPE) if auth_type == AUTH_PASSWORD: server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]] @@ -91,17 +113,19 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] ) except requests.exceptions.RequestException as ex: - _LOGGER.debug("Network error during reauth login: %s", ex) + _LOGGER.debug("Network error during credential update: %s", ex) errors["base"] = ERROR_CANNOT_CONNECT except (ValueError, KeyError, TypeError, AttributeError) as ex: - _LOGGER.debug("Invalid response format during reauth login: %s", ex) + _LOGGER.debug( + "Invalid response format during credential update: %s", ex + ) errors["base"] = ERROR_CANNOT_CONNECT else: if not isinstance(login_response, dict): errors["base"] = ERROR_CANNOT_CONNECT elif login_response.get("success"): return self.async_update_reload_and_abort( - reauth_entry, + entry, data_updates={ CONF_USERNAME: user_input[CONF_USERNAME], CONF_PASSWORD: user_input[CONF_PASSWORD], @@ -121,28 +145,26 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): try: await self.hass.async_add_executor_job(api.plant_list) except requests.exceptions.RequestException as ex: - _LOGGER.debug( - "Network error during reauth token validation: %s", ex - ) + _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: errors["base"] = ERROR_INVALID_AUTH else: _LOGGER.debug( - "Growatt V1 API error during reauth: %s (Code: %s)", + "Growatt V1 API error during credential update: %s (Code: %s)", err.error_msg or str(err), err.error_code, ) errors["base"] = ERROR_CANNOT_CONNECT except (ValueError, KeyError, TypeError, AttributeError) as ex: _LOGGER.debug( - "Invalid response format during reauth token validation: %s", ex + "Invalid response format during credential update: %s", ex ) errors["base"] = ERROR_CANNOT_CONNECT else: return self.async_update_reload_and_abort( - reauth_entry, + entry, data_updates={ CONF_TOKEN: user_input[CONF_TOKEN], CONF_URL: server_url, @@ -151,19 +173,19 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): # Determine the current region key from the stored config value. # Legacy entries may store the region key directly; newer entries store the URL. - stored_url = reauth_entry.data.get(CONF_URL, "") + stored_url = entry.data.get(CONF_URL, "") if stored_url in SERVER_URLS_NAMES: current_region = stored_url else: current_region = _URL_TO_REGION.get(stored_url, DEFAULT_URL) - auth_type = reauth_entry.data.get(CONF_AUTH_TYPE) + auth_type = entry.data.get(CONF_AUTH_TYPE) if auth_type == AUTH_PASSWORD: data_schema = vol.Schema( { vol.Required( CONF_USERNAME, - default=reauth_entry.data.get(CONF_USERNAME), + default=entry.data.get(CONF_USERNAME), ): str, vol.Required(CONF_PASSWORD): str, vol.Required(CONF_REGION, default=current_region): SelectSelector( @@ -189,8 +211,18 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN): else: return self.async_abort(reason=ERROR_CANNOT_CONNECT) + if user_input is not None: + data_schema = self.add_suggested_values_to_schema( + data_schema, + { + key: value + for key, value in user_input.items() + if key not in (CONF_PASSWORD, CONF_TOKEN) + }, + ) + return self.async_show_form( - step_id="reauth_confirm", + step_id=step_id, data_schema=data_schema, errors=errors, ) @@ -224,11 +256,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 diff --git a/homeassistant/components/growatt_server/coordinator.py b/homeassistant/components/growatt_server/coordinator.py index 7fc81e9975d..6cb9e94267d 100644 --- a/homeassistant/components/growatt_server/coordinator.py +++ b/homeassistant/components/growatt_server/coordinator.py @@ -1,7 +1,5 @@ """Coordinator module for managing Growatt data fetching.""" -from __future__ import annotations - import datetime import json import logging @@ -598,7 +596,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 +611,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..9fe6b4a4a5f 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["growattServer"], "quality_scale": "silver", - "requirements": ["growattServer==1.9.0"] + "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..7c8bd681c5f 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 diff --git a/homeassistant/components/growatt_server/quality_scale.yaml b/homeassistant/components/growatt_server/quality_scale.yaml index 48f5168eb4b..15502bdc5b0 100644 --- a/homeassistant/components/growatt_server/quality_scale.yaml +++ b/homeassistant/components/growatt_server/quality_scale.yaml @@ -34,15 +34,23 @@ 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: + 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: todo entity-category: done entity-device-class: done @@ -50,7 +58,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: Integration does not raise repairable issues. diff --git a/homeassistant/components/growatt_server/sensor/__init__.py b/homeassistant/components/growatt_server/sensor/__init__.py index c52ff2515a5..ad09f68a0a2 100644 --- a/homeassistant/components/growatt_server/sensor/__init__.py +++ b/homeassistant/components/growatt_server/sensor/__init__.py @@ -1,7 +1,5 @@ """Read status of growatt inverters.""" -from __future__ import annotations - from datetime import date, datetime import logging 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..c3412600d82 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, 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..627936d5439 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 diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json index ee65115f493..4160c5bac84 100644 --- a/homeassistant/components/growatt_server/strings.json +++ b/homeassistant/components/growatt_server/strings.json @@ -4,7 +4,8 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_plants": "No plants have been found on this account", - "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": "Cannot connect to Growatt servers. Please check your internet connection and try again.", @@ -49,6 +50,22 @@ "description": "Re-enter your credentials to continue using this integration.", "title": "Re-authenticate with Growatt" }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "region": "[%key:component::growatt_server::config::step::password_auth::data::region%]", + "token": "[%key:component::growatt_server::config::step::token_auth::data::token%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::growatt_server::config::step::password_auth::data_description::password%]", + "region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]", + "token": "[%key:component::growatt_server::config::step::token_auth::data_description::token%]", + "username": "[%key:component::growatt_server::config::step::password_auth::data_description::username%]" + }, + "description": "Update your credentials to continue using this integration.", + "title": "Reconfigure Growatt" + }, "token_auth": { "data": { "region": "[%key:component::growatt_server::config::step::password_auth::data::region%]", diff --git a/homeassistant/components/growatt_server/switch.py b/homeassistant/components/growatt_server/switch.py index 8e44e5011ca..2590acdb632 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 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..8b0ab5d1863 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 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..ddd6320a14f 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 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/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..1eb29ba65f5 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 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..a231eef5f73 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 diff --git a/homeassistant/components/habitica/notify.py b/homeassistant/components/habitica/notify.py index 8a29ac1d641..64c98a86fae 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 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..9b57aaab633 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -1,7 +1,5 @@ """Actions for the Habitica integration.""" -from __future__ import annotations - from dataclasses import asdict from datetime import UTC, date, datetime, time import logging 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..bb4c30acf3d 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 diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 858b47d6017..5a782d66141 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 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/quality_scale.yaml b/homeassistant/components/hanna/quality_scale.yaml index f4eb96842e6..405bd7a6c52 100644 --- a/homeassistant/components/hanna/quality_scale.yaml +++ b/homeassistant/components/hanna/quality_scale.yaml @@ -4,8 +4,7 @@ rules: status: exempt comment: | This integration doesn't add actions. - appropriate-polling: - status: done + appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done 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..5a94cfb5cd6 100644 --- a/homeassistant/components/hardkernel/__init__.py +++ b/homeassistant/components/hardkernel/__init__.py @@ -1,7 +1,5 @@ """The Hardkernel integration.""" -from __future__ import annotations - from homeassistant.components.hassio import get_os_info from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant 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..b536db8dd88 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 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..ed5a05eb3d6 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 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/remote.py b/homeassistant/components/harmony/remote.py index d09dc3ff7e8..ea737e22760 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 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 a65e58a1b12..7a3b9b9acd2 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -1,16 +1,12 @@ """Support for Hass.io.""" -from __future__ import annotations - import asyncio -from contextlib import suppress from dataclasses import replace from datetime import datetime import logging import os -import re import struct -from typing import Any, NamedTuple, cast +from typing import Any, cast from aiohasupervisor import SupervisorError from aiohasupervisor.models import ( @@ -27,49 +23,35 @@ from aiohasupervisor.models import ( 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.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.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import ( - ATTR_DEVICE_ID, - ATTR_NAME, EVENT_CORE_CONFIG_UPDATE, HASSIO_USER_NAME, SERVER_PORT, Platform, ) -from homeassistant.core import ( - Event, - HassJob, - HomeAssistant, - ServiceCall, - async_get_hass_or_none, - callback, -) -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.core import Event, HassJob, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery_flow, issue_registry as ir, - selector, ) 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 -from homeassistant.util.dt import now # config_flow, diagnostics, system_health, and entity platforms are imported to # ensure other dependencies that wait for hassio are not waiting @@ -92,19 +74,6 @@ from .auth import async_setup_auth_view from .config import HassioConfig from .const import ( ADDONS_COORDINATOR, - ATTR_ADDON, - ATTR_ADDONS, - ATTR_APP, - ATTR_APPS, - ATTR_COMPRESSED, - ATTR_FOLDERS, - ATTR_HOMEASSISTANT, - ATTR_HOMEASSISTANT_EXCLUDE_DATABASE, - ATTR_INPUT, - ATTR_LOCATION, - ATTR_PASSWORD, - ATTR_REPOSITORIES, - ATTR_SLUG, DATA_ADDONS_LIST, DATA_COMPONENT, DATA_CONFIG_STORE, @@ -117,11 +86,14 @@ from .const import ( DATA_STORE, DATA_SUPERVISOR_INFO, DOMAIN, - HASSIO_UPDATE_INTERVAL, - SupervisorEntityModel, + HASSIO_MAIN_UPDATE_INTERVAL, + MAIN_COORDINATOR, + STATS_COORDINATOR, ) from .coordinator import ( - HassioDataUpdateCoordinator, + HassioAddOnDataUpdateCoordinator, + HassioMainDataUpdateCoordinator, + HassioStatsDataUpdateCoordinator, get_addons_info, get_addons_list, get_addons_stats, @@ -136,15 +108,11 @@ from .coordinator import ( get_supervisor_stats, ) from .discovery import async_setup_discovery_view -from .handler import ( - HassIO, - HassioAPIError, - async_update_diagnostics, - get_supervisor_client, -) +from .handler import HassIO, async_update_diagnostics, get_supervisor_client from .http import HassIOView from .ingress import async_setup_ingress_view from .issues import SupervisorIssues +from .services import async_setup_services from .websocket_api import async_load_websocket_api # Expose the future safe name now so integrations can use it @@ -183,30 +151,8 @@ _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 = cv.empty_config_schema(DOMAIN) -CONFIG_SCHEMA = vol.Schema( - {vol.Optional(DOMAIN): vol.Schema({vol.Optional(CONF_FRONTEND_REPO): cv.isdir})}, - extra=vol.ALLOW_EXTRA, -) - -SERVICE_ADDON_START = "addon_start" -SERVICE_ADDON_STOP = "addon_stop" -SERVICE_ADDON_RESTART = "addon_restart" -SERVICE_ADDON_STDIN = "addon_stdin" -SERVICE_APP_START = "app_start" -SERVICE_APP_STOP = "app_stop" -SERVICE_APP_RESTART = "app_restart" -SERVICE_APP_STDIN = "app_stdin" -SERVICE_HOST_SHUTDOWN = "host_shutdown" -SERVICE_HOST_REBOOT = "host_reboot" -SERVICE_BACKUP_FULL = "backup_full" -SERVICE_BACKUP_PARTIAL = "backup_partial" -SERVICE_RESTORE_FULL = "restore_full" -SERVICE_RESTORE_PARTIAL = "restore_partial" -SERVICE_MOUNT_RELOAD = "mount_reload" - -VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) DEPRECATION_URL = ( "https://www.home-assistant.io/blog/2025/05/22/" @@ -214,148 +160,11 @@ DEPRECATION_URL = ( ) -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 - - -SCHEMA_NO_DATA = vol.Schema({}) - -SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon}) - -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_STDIN = SCHEMA_APP.extend( - {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} -) - -SCHEMA_BACKUP_FULL = vol.Schema( - { - vol.Optional( - ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S") - ): cv.string, - vol.Optional(ATTR_PASSWORD): cv.string, - vol.Optional(ATTR_COMPRESSED): cv.boolean, - vol.Optional(ATTR_LOCATION): vol.All( - cv.string, lambda v: None if v == "/backup" else v - ), - vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean, - } -) - -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.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( - cv.ensure_list, [VALID_ADDON_SLUG] - ), - # Legacy "addons", "apps" is preferred - vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All( - cv.ensure_list, [VALID_ADDON_SLUG] - ), - } -) - -SCHEMA_RESTORE_FULL = vol.Schema( - { - vol.Required(ATTR_SLUG): cv.slug, - vol.Optional(ATTR_PASSWORD): cv.string, - } -) - -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.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( - cv.ensure_list, [VALID_ADDON_SLUG] - ), - # Legacy "addons", "apps" is preferred - vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All( - cv.ensure_list, [VALID_ADDON_SLUG] - ), - } -) - -SCHEMA_MOUNT_RELOAD = vol.Schema( - { - vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector( - selector.DeviceSelectorConfig( - filter=selector.DeviceFilterSelectorConfig( - integration=DOMAIN, - model=SupervisorEntityModel.MOUNT, - ) - ) - ) - } -) - - def _is_32_bit() -> bool: size = struct.calcsize("P") return size * 8 == 32 -class APIEndpointSettings(NamedTuple): - """Settings for API endpoint.""" - - command: str - schema: vol.Schema - timeout: int | None = 60 - pass_data: bool = False - - -MAP_SERVICE_API = { - # Legacy addon services - SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON), - SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON), - SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON), - SERVICE_ADDON_STDIN: APIEndpointSettings( - "/addons/{addon}/stdin", SCHEMA_ADDON_STDIN - ), - # New app services - SERVICE_APP_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_APP), - SERVICE_APP_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_APP), - SERVICE_APP_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_APP), - SERVICE_APP_STDIN: APIEndpointSettings("/addons/{addon}/stdin", SCHEMA_APP_STDIN), - SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA), - SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA), - SERVICE_BACKUP_FULL: APIEndpointSettings( - "/backups/new/full", - SCHEMA_BACKUP_FULL, - None, - True, - ), - SERVICE_BACKUP_PARTIAL: APIEndpointSettings( - "/backups/new/partial", - SCHEMA_BACKUP_PARTIAL, - None, - True, - ), - SERVICE_RESTORE_FULL: APIEndpointSettings( - "/backups/{slug}/restore/full", - SCHEMA_RESTORE_FULL, - None, - True, - ), - SERVICE_RESTORE_PARTIAL: APIEndpointSettings( - "/backups/{slug}/restore/partial", - SCHEMA_RESTORE_PARTIAL, - None, - True, - ), -} - HARDWARE_INTEGRATIONS = { "green": "homeassistant_green", "odroid-c2": "hardkernel", @@ -379,7 +188,7 @@ def hostname_from_addon_slug(addon_slug: str) -> str: return addon_slug.replace("_", "-") -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Hass.io component.""" # Check local setup for env in ("SUPERVISOR", "SUPERVISOR_TOKEN"): @@ -397,7 +206,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: host = os.environ["SUPERVISOR"] websession = async_get_clientsession(hass) - hass.data[DATA_COMPONENT] = hassio = HassIO(hass.loop, websession, host) + hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host) supervisor_client = get_supervisor_client(hass) try: @@ -431,30 +240,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: 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, - ) - ] - ) - hass.http.register_view(HassIOView(host, websession)) - 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, - ) - async def update_hass_api(http_config: dict[str, Any], refresh_token: RefreshToken): """Update Home Assistant API data on Hass.io.""" options = HomeAssistantOptions( @@ -510,74 +297,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass) issues_task = hass.async_create_task(issues.setup(), eager_start=True) - async def async_service_handler(service: ServiceCall) -> None: - """Handle service calls for Hass.io.""" - api_endpoint = MAP_SERVICE_API[service.service] - - data = service.data.copy() - addon = data.pop(ATTR_APP, None) or data.pop(ATTR_ADDON, None) - slug = data.pop(ATTR_SLUG, None) - - if addons := data.pop(ATTR_APPS, None) or data.pop(ATTR_ADDONS, None): - data[ATTR_ADDONS] = addons - - payload = None - - # Pass data to Hass.io API - if service.service in (SERVICE_ADDON_STDIN, SERVICE_APP_STDIN): - payload = data[ATTR_INPUT] - elif api_endpoint.pass_data: - payload = data - - # Call API - # The exceptions are logged properly in hassio.send_command - with suppress(HassioAPIError): - await hassio.send_command( - api_endpoint.command.format(addon=addon, slug=slug), - payload=payload, - timeout=api_endpoint.timeout, - ) - - for service, settings in MAP_SERVICE_API.items(): - hass.services.async_register( - DOMAIN, service, async_service_handler, schema=settings.schema - ) - - dev_reg = dr.async_get(hass) - - async def async_mount_reload(service: ServiceCall) -> None: - """Handle service calls for Hass.io.""" - coordinator: HassioDataUpdateCoordinator | None = None - - if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="mount_reload_unknown_device_id", - ) - - if ( - device.name is None - or device.model != SupervisorEntityModel.MOUNT - or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None - or coordinator.entry_id not in device.config_entries - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="mount_reload_invalid_device", - ) - - try: - await supervisor_client.mounts.reload_mount(device.name) - except SupervisorError as error: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="mount_reload_error", - translation_placeholders={"name": device.name, "error": str(error)}, - ) from error - - hass.services.async_register( - DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD - ) + # Register services + async_setup_services(hass, supervisor_client) async def update_info_data(_: datetime | None = None) -> None: """Update last available supervisor information.""" @@ -619,27 +340,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: 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), - ) + hass.data[DATA_INFO] = root_info + hass.data[DATA_HOST_INFO] = host_info + hass.data[DATA_STORE] = store_info + hass.data[DATA_CORE_INFO] = homeassistant_info + hass.data[DATA_SUPERVISOR_INFO] = supervisor_info + hass.data[DATA_OS_INFO] = os_info + hass.data[DATA_NETWORK_INFO] = network_info + hass.data[DATA_ADDONS_LIST] = addons_list # Fetch data update_info_task = hass.async_create_task(update_info_data(), eager_start=True) @@ -687,7 +395,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # os info not yet fetched from supervisor, retry later async_call_later( hass, - HASSIO_UPDATE_INTERVAL, + HASSIO_MAIN_UPDATE_INTERVAL, async_setup_hardware_integration_job, ) return @@ -713,9 +421,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: 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) + + coordinator = HassioMainDataUpdateCoordinator(hass, entry, dev_reg) await coordinator.async_config_entry_first_refresh() - hass.data[ADDONS_COORDINATOR] = coordinator + 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 def deprecated_setup_issue() -> None: os_info = get_os_info(hass) @@ -782,10 +501,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) # Unload coordinator - coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR] + coordinator: HassioMainDataUpdateCoordinator = hass.data[MAIN_COORDINATOR] coordinator.unload() - # Pop coordinator + # Pop coordinators + 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 f176967923f..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 @@ -26,7 +24,7 @@ from aiohasupervisor.models import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from .handler import HassioAPIError, get_supervisor_client +from .handler import get_supervisor_client type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]] type _ReturnFuncType[_T, **_P, _R] = Callable[ @@ -36,18 +34,15 @@ type _ReturnFuncType[_T, **_P, _R] = Callable[ def api_error[_AddonManagerT: AddonManager, **_P, _R]( error_message: str, - *, - expected_error_type: type[HassioAPIError | SupervisorError] | None = None, ) -> Callable[ [_FuncType[_AddonManagerT, _P, _R]], _ReturnFuncType[_AddonManagerT, _P, _R] ]: - """Handle HassioAPIError and raise a specific AddonError.""" - error_type = expected_error_type or (HassioAPIError, SupervisorError) + """Handle SupervisorError and raise a specific AddonError.""" - def handle_hassio_api_error( + def handle_supervisor_error( func: _FuncType[_AddonManagerT, _P, _R], ) -> _ReturnFuncType[_AddonManagerT, _P, _R]: - """Handle a HassioAPIError.""" + """Handle a SupervisorError.""" @wraps(func) async def wrapper( @@ -56,7 +51,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R]( """Wrap an add-on manager method.""" try: return_value = await func(self, *args, **kwargs) - except error_type as err: + except SupervisorError as err: raise AddonError( f"{error_message.format(addon_name=self.addon_name)}: {err}" ) from err @@ -65,7 +60,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R]( return wrapper - return handle_hassio_api_error + return handle_supervisor_error @dataclass @@ -128,10 +123,7 @@ class AddonManager: ) ) - @api_error( - "Failed to get the {addon_name} app discovery info", - expected_error_type=SupervisorError, - ) + @api_error("Failed to get the {addon_name} app discovery info") async def async_get_addon_discovery_info(self) -> dict: """Return add-on discovery info.""" discovery_info = next( @@ -148,10 +140,7 @@ class AddonManager: return discovery_info.config - @api_error( - "Failed to get the {addon_name} app info", - expected_error_type=SupervisorError, - ) + @api_error("Failed to get the {addon_name} app info") async def async_get_addon_info(self) -> AddonInfo: """Return and cache manager add-on info.""" addon_store_info = await self._supervisor_client.store.addon_info( @@ -199,19 +188,14 @@ class AddonManager: version=addon_info.version, ) - @api_error( - "Failed to set the {addon_name} app options", - expected_error_type=SupervisorError, - ) + @api_error("Failed to set the {addon_name} app options") async def async_set_addon_options(self, config: dict) -> None: """Set manager add-on options.""" await self._supervisor_client.addons.set_addon_options( self.addon_slug, AddonsOptions(config=config) ) - @api_error( - "Failed to install the {addon_name} app", expected_error_type=SupervisorError - ) + @api_error("Failed to install the {addon_name} app") async def async_install_addon(self) -> None: """Install the managed add-on.""" try: @@ -221,10 +205,7 @@ class AddonManager: f"{self.addon_name} app is not available: {err!s}" ) from None - @api_error( - "Failed to uninstall the {addon_name} app", - expected_error_type=SupervisorError, - ) + @api_error("Failed to uninstall the {addon_name} app") async def async_uninstall_addon(self) -> None: """Uninstall the managed add-on.""" await self._supervisor_client.addons.uninstall_addon(self.addon_slug) @@ -259,31 +240,22 @@ class AddonManager: self.addon_slug, StoreAddonUpdate(backup=False) ) - @api_error( - "Failed to start the {addon_name} app", expected_error_type=SupervisorError - ) + @api_error("Failed to start the {addon_name} app") async def async_start_addon(self) -> None: """Start the managed add-on.""" await self._supervisor_client.addons.start_addon(self.addon_slug) - @api_error( - "Failed to restart the {addon_name} app", expected_error_type=SupervisorError - ) + @api_error("Failed to restart the {addon_name} app") async def async_restart_addon(self) -> None: """Restart the managed add-on.""" await self._supervisor_client.addons.restart_addon(self.addon_slug) - @api_error( - "Failed to stop the {addon_name} app", expected_error_type=SupervisorError - ) + @api_error("Failed to stop the {addon_name} app") async def async_stop_addon(self) -> None: """Stop the managed add-on.""" await self._supervisor_client.addons.stop_addon(self.addon_slug) - @api_error( - "Failed to create a backup of the {addon_name} app", - expected_error_type=SupervisorError, - ) + @api_error("Failed to create a backup of the {addon_name} app") async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None: """Create a partial backup of the managed add-on.""" if addon_info: diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index 2a88788a2b5..92dcc6435f9 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -8,7 +8,7 @@ from aiohasupervisor.models import IngressPanel from aiohttp import web from homeassistant.components import frontend -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.core import HomeAssistant from .handler import get_supervisor_client @@ -43,6 +43,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 +57,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..9c9d7cc710e 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -12,6 +12,7 @@ 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 @@ -41,14 +42,18 @@ class HassIOBaseAuth(HomeAssistantView): 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 + # 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: 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..6978b545766 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -1,7 +1,5 @@ """Hass.io const variables.""" -from __future__ import annotations - from datetime import timedelta from enum import StrEnum from typing import TYPE_CHECKING @@ -9,8 +7,27 @@ from typing import TYPE_CHECKING 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 .config import HassioConfig + from .coordinator import ( + HassioAddOnDataUpdateCoordinator, + HassioMainDataUpdateCoordinator, + HassioStatsDataUpdateCoordinator, + ) from .handler import HassIO + from .issues import SupervisorIssues DOMAIN = "hassio" @@ -55,8 +72,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,24 +92,38 @@ 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" @@ -114,7 +143,7 @@ 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" PLACEHOLDER_KEY_ADDON = "addon" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 679614acbec..5ca558fbc72 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,9 +59,15 @@ 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 .handler import get_supervisor_client @@ -73,64 +79,190 @@ 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: """Return generic information from Supervisor. Async friendly. """ - return hass.data.get(DATA_INFO) + info = hass.data.get(DATA_INFO) + return info.to_dict() if info is not None else None @callback -@bind_hass def get_host_info(hass: HomeAssistant) -> dict[str, Any] | None: """Return generic host information. Async friendly. """ - return hass.data.get(DATA_HOST_INFO) + info = hass.data.get(DATA_HOST_INFO) + return info.to_dict() if info is not None else None @callback -@bind_hass def get_store(hass: HomeAssistant) -> dict[str, Any] | None: """Return store information. Async friendly. """ - return hass.data.get(DATA_STORE) + info = hass.data.get(DATA_STORE) + return info.to_dict() if info is not None else None @callback -@bind_hass def get_supervisor_info(hass: HomeAssistant) -> dict[str, Any] | None: """Return Supervisor information. Async friendly. """ - return hass.data.get(DATA_SUPERVISOR_INFO) + info = hass.data.get(DATA_SUPERVISOR_INFO) + if info is None: + return None + 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: """Return Host Network information. Async friendly. """ - return hass.data.get(DATA_NETWORK_INFO) + info = hass.data.get(DATA_NETWORK_INFO) + return info.to_dict() if info is not None else None @callback -@bind_hass def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any] | None] | 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: + return None + # 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 @@ -139,61 +271,64 @@ def get_addons_list(hass: HomeAssistant) -> list[dict[str, Any]] | None: Async friendly. """ - return hass.data.get(DATA_ADDONS_LIST) + addons = hass.data.get(DATA_ADDONS_LIST) + return [addon.to_dict() for addon in addons] if addons is not None else None @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: """Return OS information. Async friendly. """ - return hass.data.get(DATA_OS_INFO) + info = hass.data.get(DATA_OS_INFO) + return info.to_dict() if info is not None else None @callback -@bind_hass def get_core_info(hass: HomeAssistant) -> dict[str, Any] | None: """Return Home Assistant Core information from Supervisor. Async friendly. """ - return hass.data.get(DATA_CORE_INFO) + info = hass.data.get(DATA_CORE_INFO) + return info.to_dict() if info is not None else None @callback -@bind_hass def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None: """Return Supervisor issues info. @@ -204,19 +339,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 +378,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 +412,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 +430,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 +454,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 +771,107 @@ 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) - ) + if info := self.hass.data.get(DATA_INFO): + self.is_hass_os = info.hassos is not None + else: + 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 + 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 +881,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 +892,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 +911,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 +928,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.""" + 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/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..dde3b630f60 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 diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 3d8a9aed6cb..b1555bab3da 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -1,7 +1,5 @@ """Supervisor events monitor.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass, field from datetime import datetime @@ -28,7 +26,6 @@ from homeassistant.helpers.issue_registry import ( ) from .const import ( - ADDONS_COORDINATOR, ATTR_DATA, ATTR_HEALTHY, ATTR_SLUG, @@ -54,6 +51,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 +60,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" @@ -417,8 +415,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/repairs.py b/homeassistant/components/hassio/repairs.py index 11dbb939749..d4887470cdb 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 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 new file mode 100644 index 00000000000..c141015e4a2 --- /dev/null +++ b/homeassistant/components/hassio/services.py @@ -0,0 +1,454 @@ +"""Set up Supervisor services.""" + +from collections.abc import Awaitable, Callable +import json +import re +from typing import Any + +from aiohasupervisor import SupervisorClient, SupervisorError +from aiohasupervisor.models import ( + FullBackupOptions, + FullRestoreOptions, + PartialBackupOptions, + PartialRestoreOptions, +) +import voluptuous as vol + +from homeassistant.const import ATTR_DEVICE_ID, 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, + selector, +) +from homeassistant.helpers.service import async_register_admin_service +from homeassistant.util.dt import now + +from .const import ( + ATTR_ADDON, + ATTR_ADDONS, + ATTR_APP, + ATTR_APPS, + ATTR_COMPRESSED, + ATTR_FOLDERS, + ATTR_HOMEASSISTANT, + ATTR_HOMEASSISTANT_EXCLUDE_DATABASE, + ATTR_INPUT, + ATTR_LOCATION, + ATTR_PASSWORD, + ATTR_SLUG, + DOMAIN, + MAIN_COORDINATOR, + SupervisorEntityModel, +) +from .coordinator import HassioMainDataUpdateCoordinator, get_addons_info + +SERVICE_ADDON_START = "addon_start" +SERVICE_ADDON_STOP = "addon_stop" +SERVICE_ADDON_RESTART = "addon_restart" +SERVICE_ADDON_STDIN = "addon_stdin" +SERVICE_APP_START = "app_start" +SERVICE_APP_STOP = "app_stop" +SERVICE_APP_RESTART = "app_restart" +SERVICE_APP_STDIN = "app_stdin" +SERVICE_HOST_SHUTDOWN = "host_shutdown" +SERVICE_HOST_REBOOT = "host_reboot" +SERVICE_BACKUP_FULL = "backup_full" +SERVICE_BACKUP_PARTIAL = "backup_partial" +SERVICE_RESTORE_FULL = "restore_full" +SERVICE_RESTORE_PARTIAL = "restore_partial" +SERVICE_MOUNT_RELOAD = "mount_reload" + + +VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) + + +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 + + +SCHEMA_NO_DATA = vol.Schema({}) + +SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon}) + +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_STDIN = SCHEMA_APP.extend( + {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} +) + +SCHEMA_BACKUP_FULL = vol.Schema( + { + vol.Optional( + ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S") + ): cv.string, + vol.Optional(ATTR_PASSWORD): cv.string, + vol.Optional(ATTR_COMPRESSED): cv.boolean, + vol.Optional(ATTR_LOCATION): vol.All( + cv.string, lambda v: None if v == "/backup" else v + ), + vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean, + } +) + +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) + ), + vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( + cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) + ), + # Legacy "addons", "apps" is preferred + vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All( + cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) + ), + } +) + +SCHEMA_RESTORE_FULL = vol.Schema( + { + vol.Required(ATTR_SLUG): cv.slug, + vol.Optional(ATTR_PASSWORD): cv.string, + } +) + +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) + ), + vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All( + cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) + ), + # Legacy "addons", "apps" is preferred + vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All( + cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set) + ), + } +) + +SCHEMA_MOUNT_RELOAD = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector( + selector.DeviceSelectorConfig( + filter=selector.DeviceFilterSelectorConfig( + integration=DOMAIN, + model=SupervisorEntityModel.MOUNT, + ) + ) + ) + } +) + + +@callback +def async_setup_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register the Supervisor services.""" + async_register_app_services(hass, supervisor_client) + async_register_host_services(hass, supervisor_client) + async_register_backup_restore_services(hass, supervisor_client) + async_register_network_storage_services(hass, supervisor_client) + + +@callback +def async_register_app_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register app services.""" + simple_app_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = { + SERVICE_APP_START: ("start", supervisor_client.addons.start_addon), + SERVICE_APP_RESTART: ("restart", supervisor_client.addons.restart_addon), + SERVICE_APP_STOP: ("stop", supervisor_client.addons.stop_addon), + } + + async def async_simple_app_service_handler(service: ServiceCall) -> None: + """Handles app services which only take a slug and have no response.""" + action, api_method = simple_app_services[service.service] + app_slug = service.data[ATTR_APP] + + try: + await api_method(app_slug) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to {action} app {app_slug}: {err}" + ) from err + + for service in simple_app_services: + 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: + """Handles app stdin service.""" + app_slug = service.data[ATTR_APP] + data: dict | str = service.data[ATTR_INPUT] + + # For backwards compatibility the payload here must be valid json + # This is sensible when a dictionary is provided, it must be serialized + # If user provides a string though, we wrap it in quotes before encoding + # This is purely for legacy reasons, Supervisor has no json requirement + # Supervisor just hands the raw request as binary to the container + data = json.dumps(data) + payload = data.encode(encoding="utf-8") + + try: + await supervisor_client.addons.write_addon_stdin(app_slug, payload) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to write stdin to app {app_slug}: {err}" + ) from err + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_APP_STDIN, + async_app_stdin_service_handler, + schema=SCHEMA_APP_STDIN, + ) + + # LEGACY - Register equivalent addon services for compatibility + simple_addon_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = { + SERVICE_ADDON_START: ("start", supervisor_client.addons.start_addon), + SERVICE_ADDON_RESTART: ("restart", supervisor_client.addons.restart_addon), + SERVICE_ADDON_STOP: ("stop", supervisor_client.addons.stop_addon), + } + + async def async_simple_addon_service_handler(service: ServiceCall) -> None: + """Handles addon services which only take a slug and have no response.""" + action, api_method = simple_addon_services[service.service] + addon_slug = service.data[ATTR_ADDON] + + try: + await api_method(addon_slug) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to {action} app {addon_slug}: {err}" + ) from err + + for service in simple_addon_services: + 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: + """Handles addon stdin service.""" + 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 + data = json.dumps(data) + payload = data.encode(encoding="utf-8") + + try: + await supervisor_client.addons.write_addon_stdin(addon_slug, payload) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to write stdin to app {addon_slug}: {err}" + ) from err + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_ADDON_STDIN, + async_addon_stdin_service_handler, + schema=SCHEMA_ADDON_STDIN, + ) + + +@callback +def async_register_host_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register host services.""" + simple_host_services: dict[str, tuple[str, Callable[[], Awaitable[None]]]] = { + SERVICE_HOST_REBOOT: ("reboot", supervisor_client.host.reboot), + SERVICE_HOST_SHUTDOWN: ("shutdown", supervisor_client.host.shutdown), + } + + async def async_simple_host_service_handler(service: ServiceCall) -> None: + """Handler for host services that take no input and return no response.""" + action, api_method = simple_host_services[service.service] + try: + await api_method() + except SupervisorError as err: + raise HomeAssistantError(f"Failed to {action} the host: {err}") from err + + for service in simple_host_services: + async_register_admin_service( + hass, + DOMAIN, + service, + async_simple_host_service_handler, + schema=SCHEMA_NO_DATA, + ) + + +@callback +def async_register_backup_restore_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register backup and restore services.""" + + async def async_full_backup_service_handler( + service: ServiceCall, + ) -> ServiceResponse: + """Handler for create full backup service. Returns the new backup's ID.""" + options = FullBackupOptions(**service.data) + try: + backup = await supervisor_client.backups.full_backup(options) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to create full backup {options.name}: {err}" + ) from err + + return {"backup": backup.slug} + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_BACKUP_FULL, + async_full_backup_service_handler, + schema=SCHEMA_BACKUP_FULL, + supports_response=SupportsResponse.OPTIONAL, + ) + + async def async_partial_backup_service_handler( + 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) + options = PartialBackupOptions(**data) + + try: + backup = await supervisor_client.backups.partial_backup(options) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to create partial backup {options.name}: {err}" + ) from err + + return {"backup": backup.slug} + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_BACKUP_PARTIAL, + async_partial_backup_service_handler, + schema=SCHEMA_BACKUP_PARTIAL, + supports_response=SupportsResponse.OPTIONAL, + ) + + async def async_full_restore_service_handler(service: ServiceCall) -> None: + """Handler for full restore service.""" + backup_slug = service.data[ATTR_SLUG] + options: FullRestoreOptions | None = None + if ATTR_PASSWORD in service.data: + options = FullRestoreOptions(password=service.data[ATTR_PASSWORD]) + + try: + await supervisor_client.backups.full_restore(backup_slug, options) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to full restore from backup {backup_slug}: {err}" + ) from err + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_RESTORE_FULL, + async_full_restore_service_handler, + schema=SCHEMA_RESTORE_FULL, + ) + + async def async_partial_restore_service_handler(service: ServiceCall) -> None: + """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) + options = PartialRestoreOptions(**data) + + try: + await supervisor_client.backups.partial_restore(backup_slug, options) + except SupervisorError as err: + raise HomeAssistantError( + f"Failed to partial restore from backup {backup_slug}: {err}" + ) from err + + async_register_admin_service( + hass, + DOMAIN, + SERVICE_RESTORE_PARTIAL, + async_partial_restore_service_handler, + schema=SCHEMA_RESTORE_PARTIAL, + ) + + +@callback +def async_register_network_storage_services( + hass: HomeAssistant, supervisor_client: SupervisorClient +) -> None: + """Register network storage (or mount) services.""" + dev_reg = dr.async_get(hass) + + async def async_mount_reload(service: ServiceCall) -> None: + """Handle service calls for Hass.io.""" + coordinator: HassioMainDataUpdateCoordinator | None = None + + if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mount_reload_unknown_device_id", + ) + + if ( + device.name is None + or device.model != SupervisorEntityModel.MOUNT + or (coordinator := hass.data.get(MAIN_COORDINATOR)) is None + or coordinator.entry_id not in device.config_entries + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="mount_reload_invalid_device", + ) + + try: + await supervisor_client.mounts.reload_mount(device.name) + except SupervisorError as error: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="mount_reload_error", + translation_placeholders={"name": device.name, "error": str(error)}, + ) from error + + async_register_admin_service( + hass, DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD + ) 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..b53577dd249 100644 --- a/homeassistant/components/hassio/system_health.py +++ b/homeassistant/components/hassio/system_health.py @@ -1,7 +1,5 @@ """Provide info to system health.""" -from __future__ import annotations - import os from typing import Any diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 5354f21e726..7005f1ac324 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 @@ -152,13 +162,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 +226,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: @@ -227,25 +260,44 @@ 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: @@ -264,13 +316,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 +381,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: 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 534106c4957..4362eca1985 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -18,7 +18,6 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) -from . import HassioAPIError from .config import HassioUpdateParametersDict from .const import ( ATTR_DATA, @@ -40,6 +39,7 @@ from .const import ( WS_TYPE_SUBSCRIBE, ) from .coordinator import get_addons_list +from .handler import HassioAPIError from .update_helper import update_addon, update_core SCHEMA_WEBSOCKET_EVENT = vol.Schema( @@ -47,15 +47,15 @@ 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 +92,7 @@ def websocket_subscribe( @callback +@websocket_api.ws_require_user(only_supervisor=True) @websocket_api.websocket_command( { vol.Required(WS_TYPE): WS_TYPE_EVENT, @@ -150,7 +151,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 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..3e168ed52b5 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 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..19aa786cc88 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 @@ -55,9 +54,10 @@ class CecEntity(Entity): else: self._attr_name = f"{self._device.type_name} {self._logical_address} ({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..b3d68a04ac8 100644 --- a/homeassistant/components/hdmi_cec/media_player.py +++ b/homeassistant/components/hdmi_cec/media_player.py @@ -1,9 +1,6 @@ """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 ( @@ -31,7 +28,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 +41,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 +75,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._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..b85ea10b881 100644 --- a/homeassistant/components/hdmi_cec/switch.py +++ b/homeassistant/components/hdmi_cec/switch.py @@ -1,7 +1,5 @@ """Support for HDMI CEC devices as switches.""" -from __future__ import annotations - import logging from typing import Any @@ -20,10 +18,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 +31,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 +42,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._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..8f40ee2fe45 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 diff --git a/homeassistant/components/hegel/const.py b/homeassistant/components/hegel/const.py index dd684d7db40..692ac267e5c 100644 --- a/homeassistant/components/hegel/const.py +++ b/homeassistant/components/hegel/const.py @@ -81,6 +81,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..535b3915a5e 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 diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 54510540f2a..91e3d316b38 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 diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index b0372145f82..be9f4ee21fd 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 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..c4e24528b1d 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 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..f604da18694 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 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/hikvisioncam/__init__.py b/homeassistant/components/hikvisioncam/__init__.py index 32a2a86b28f..6f832338104 100644 --- a/homeassistant/components/hikvisioncam/__init__.py +++ b/homeassistant/components/hikvisioncam/__init__.py @@ -1 +1 @@ -"""The hikvisioncam component.""" +"""The Hikvision integration.""" 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..9c60760de4a 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=hass-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..b95ae546987 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=hass-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..17f9661d846 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 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..ca22dbb8e81 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,22 @@ 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) @@ -173,6 +184,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 +231,8 @@ class HiveOptionsFlowHandler(OptionsFlow): schema = vol.Schema( { + # Polling interval is user-configurable, which is no longer allowed + # pylint: disable-next=hass-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/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..8bf09c82b26 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 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..2b6f8776710 100644 --- a/homeassistant/components/hive/switch.py +++ b/homeassistant/components/hive/switch.py @@ -1,7 +1,5 @@ """Support for the Hive switches.""" -from __future__ import annotations - from datetime import timedelta from typing import Any 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 5845f091fa7..b2199a39ad1 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.84", "babel==2.15.0"] + "requirements": ["holidays==0.96", "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..eccb3301f9f 100644 --- a/homeassistant/components/home_connect/climate.py +++ b/homeassistant/components/home_connect/climate.py @@ -179,13 +179,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, ) ) @@ -215,9 +215,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 ( diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 9090859456d..14e675b7e95 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" @@ -77,7 +78,12 @@ AFFECTS_TO_SELECTED_PROGRAM = "selected_program" TRANSLATION_KEYS_PROGRAMS_MAP = { bsh_key_to_translation_key(program.value): program for program in ProgramKey - if program not in (ProgramKey.UNKNOWN, ProgramKey.BSH_COMMON_FAVORITE_001) + if program + not in ( + ProgramKey.UNKNOWN, + ProgramKey.BSH_COMMON_FAVORITE_001, + ProgramKey.BSH_COMMON_FAVORITE_002, + ) } PROGRAMS_TRANSLATION_KEYS_MAP = { diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 7b8c04f8d23..15c6be8806a 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 @@ -533,7 +531,11 @@ class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectAppliance current_program_key = program.key program_options = program.options if ( - current_program_key == ProgramKey.BSH_COMMON_FAVORITE_001 + current_program_key + in ( + ProgramKey.BSH_COMMON_FAVORITE_001, + ProgramKey.BSH_COMMON_FAVORITE_002, + ) and program_options ): # The API doesn't allow to fetch the options from the favorite program. @@ -616,7 +618,11 @@ class HomeConnectApplianceCoordinator(DataUpdateCoordinator[HomeConnectAppliance options_to_notify = options.copy() options.clear() if ( - program_key == ProgramKey.BSH_COMMON_FAVORITE_001 + program_key + in ( + ProgramKey.BSH_COMMON_FAVORITE_001, + ProgramKey.BSH_COMMON_FAVORITE_002, + ) and (event := events.get(EventKey.BSH_COMMON_OPTION_BASE_PROGRAM)) and isinstance(event.value, str) ): @@ -629,16 +635,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, ) 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/fan.py b/homeassistant/components/home_connect/fan.py index 5188fc34daf..e8410a9aaa2 100644 --- a/homeassistant/components/home_connect/fan.py +++ b/homeassistant/components/home_connect/fan.py @@ -84,7 +84,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,7 +104,7 @@ 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, ) ) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index d5955e07e22..da7dcb7822b 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.33.0"], + "requirements": ["aiohomeconnect==0.36.0"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py index ee0926768e5..164d27ca644 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -436,7 +436,11 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): else None ) if ( - program_key == ProgramKey.BSH_COMMON_FAVORITE_001 + program_key + in ( + ProgramKey.BSH_COMMON_FAVORITE_001, + ProgramKey.BSH_COMMON_FAVORITE_002, + ) and ( base_program_event := self.appliance.events.get( EventKey.BSH_COMMON_OPTION_BASE_PROGRAM diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index 810d7ad356d..283fc7dfea4 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, @@ -57,6 +58,7 @@ BSH_PROGRAM_SENSORS = ( "CookProcessor", "Dishwasher", "Dryer", + "Microwave", "Hood", "Oven", "Washer", @@ -198,7 +200,7 @@ EVENT_SENSORS = ( options=EVENT_OPTIONS, default_value="off", translation_key="program_aborted", - appliance_types=("Dishwasher", "CleaningRobot", "CookProcessor"), + appliance_types=("Dishwasher", "Microwave", "CleaningRobot", "CookProcessor"), ), HomeConnectSensorEntityDescription( key=EventKey.BSH_COMMON_EVENT_PROGRAM_FINISHED, @@ -211,6 +213,7 @@ EVENT_SENSORS = ( "Dishwasher", "Washer", "Dryer", + "Microwave", "WasherDryer", "CleaningRobot", "CookProcessor", @@ -599,8 +602,6 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): class HomeConnectProgramSensor(HomeConnectSensor): """Sensor class for Home Connect sensors that reports information related to the running program.""" - program_running: bool = False - async def async_added_to_hass(self) -> None: """Register listener.""" await super().async_added_to_hass() @@ -614,18 +615,22 @@ class HomeConnectProgramSensor(HomeConnectSensor): @callback def _handle_operation_state_event(self) -> None: """Update status when an event for the entity is received.""" - self.program_running = ( - status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) - ) is not None and status.value in [ - BSH_OPERATION_STATE_RUN, - BSH_OPERATION_STATE_PAUSE, - BSH_OPERATION_STATE_FINISHED, - ] if not self.program_running: # reset the value when the program is not running, paused or finished self._attr_native_value = None self.async_write_ha_state() + @property + def program_running(self) -> bool: + """Return whether a program is running, paused or finished.""" + status = self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) + return status is not None and status.value in [ + BSH_OPERATION_STATE_DELAYED_START, + BSH_OPERATION_STATE_RUN, + BSH_OPERATION_STATE_PAUSE, + BSH_OPERATION_STATE_FINISHED, + ] + @property def available(self) -> bool: """Return true if the sensor is available.""" @@ -635,13 +640,6 @@ class HomeConnectProgramSensor(HomeConnectSensor): def update_native_value(self) -> None: """Update the program sensor's status.""" - self.program_running = ( - status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE) - ) is not None and status.value in [ - BSH_OPERATION_STATE_RUN, - BSH_OPERATION_STATE_PAUSE, - BSH_OPERATION_STATE_FINISHED, - ] event = self.appliance.events.get(cast(EventKey, self.bsh_key)) if event: self._update_native_value(event.value) diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py index bb9783be62b..c4812d83077 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 @@ -68,8 +66,16 @@ PROGRAM_OPTIONS = { ), 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..af9a2400459 100644 --- a/homeassistant/components/home_connect/services.yaml +++ b/homeassistant/components/home_connect/services.yaml @@ -119,7 +119,7 @@ set_program_and_options: - 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_3_d_hot_air - cooking_oven_program_heating_mode_air_fry - cooking_oven_program_heating_mode_grill_large_area - cooking_oven_program_heating_mode_grill_small_area @@ -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/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 9583857660f..10d4b70e7b7 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -452,6 +452,16 @@ 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", + 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) 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..e44ac020fcc 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 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..efc6b7f4cc5 100644 --- a/homeassistant/components/homeassistant/repairs.py +++ b/homeassistant/components/homeassistant/repairs.py @@ -1,7 +1,5 @@ """Repairs for Home Assistant.""" -from __future__ import annotations - from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py index 33ae659f0f6..c294276f0a1 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 diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index dd69ab6b4a6..b87ac7d8136 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -94,7 +94,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 +106,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}).", @@ -203,6 +203,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": { 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..431d080ca09 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 diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 27c63742f7b..0fc5618c122 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -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) 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_connect_zbt2/__init__.py b/homeassistant/components/homeassistant_connect_zbt2/__init__.py index cbd88114e66..72ad24f7cf1 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 diff --git a/homeassistant/components/homeassistant_connect_zbt2/config_flow.py b/homeassistant/components/homeassistant_connect_zbt2/config_flow.py index cca11596259..d0e698beef1 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 diff --git a/homeassistant/components/homeassistant_connect_zbt2/hardware.py b/homeassistant/components/homeassistant_connect_zbt2/hardware.py index 0d45e055407..16317826c4d 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 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..dac83320e35 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 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..b66e95d0b55 100644 --- a/homeassistant/components/homeassistant_green/__init__.py +++ b/homeassistant/components/homeassistant_green/__init__.py @@ -1,7 +1,5 @@ """The Home Assistant Green integration.""" -from __future__ import annotations - from homeassistant.components.hassio import get_os_info from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant 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..900bc636e6c 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 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..b17e8f22004 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 diff --git a/homeassistant/components/homeassistant_hardware/helpers.py b/homeassistant/components/homeassistant_hardware/helpers.py index 58337362f0e..9335eccbb4f 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 diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index be6de115b78..96e78b80ca3 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -7,8 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "integration_type": "system", "requirements": [ - "serialx==0.6.2", - "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/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index b32998f55b0..dacdbd0b759 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -1,7 +1,5 @@ """Manage the Silicon Labs Multiprotocol add-on.""" -from __future__ import annotations - from abc import ABC, abstractmethod import asyncio import dataclasses 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..9dc629ad8c5 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 diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index e8e57b2ae48..ba514aacf34 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,12 @@ 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, + get_apps_list, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -26,6 +29,8 @@ 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, @@ -84,6 +89,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.""" @@ -212,6 +228,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 == 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 +263,54 @@ 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) + multipan_addon_manager = await get_multiprotocol_addon_manager(hass) - 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") + 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") - 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) - ], - ) + 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 + for app_info in get_apps_list(hass) or []: + 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 943892fc910..014954223d4 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 @@ -13,7 +11,7 @@ from homeassistant.components.homeassistant_hardware.util import guess_firmware_ 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.core import HomeAssistant, callback @@ -163,7 +161,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] @@ -172,6 +170,8 @@ async def async_migrate_entry( f"USB device {device} is missing, cannot migrate" ) + assert isinstance(usb_info, USBDevice) + hass.config_entries.async_update_entry( config_entry, data={ diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 654714aa243..ca66d5235eb 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 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/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..b60f7c67ec0 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 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..38ee287f702 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -1,7 +1,5 @@ """The Home Assistant Yellow integration.""" -from __future__ import annotations - from dataclasses import dataclass import logging @@ -16,8 +14,13 @@ 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 @@ -26,6 +29,7 @@ from homeassistant.helpers.hassio import is_hassio from .const import ( FIRMWARE, FIRMWARE_VERSION, + MANUFACTURER, NABU_CASA_FIRMWARE_RELEASES_URL, RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA, @@ -80,6 +84,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( diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index acbdb1f1a58..0237b50e859 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 diff --git a/homeassistant/components/homeassistant_yellow/hardware.py b/homeassistant/components/homeassistant_yellow/hardware.py index 0772b27f936..f7e9f97360c 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 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/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..13f2d7957a0 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 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/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index ce08feaaebb..8e3ae11df8a 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 @@ -979,7 +977,7 @@ class HomeKit: for entry in dev_reg.devices.get_devices_for_config_entry_id(self._entry_id) if ( identifier not in entry.identifiers # type: ignore[comparison-overlap] - or connection not in entry.connections + or connection not in entry.connections # type: ignore[unreachable] ) ] diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 06fc0a1c493..337807d5459 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 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..fde5df11c3e 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 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..eb06b79490e 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.2", "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..c8841f4987d 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -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_lights.py b/homeassistant/components/homekit/type_lights.py index 212b3228154..33e86343c5b 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 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..9ab020e3297 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 diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index ebf1bd97c5b..783a66ea261 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -49,14 +49,21 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.water_heater import ( + ATTR_OPERATION_LIST, + ATTR_OPERATION_MODE, DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_WATER_HEATER, + WaterHeaterEntityFeature, ) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, PERCENTAGE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfTemperature, @@ -745,6 +752,7 @@ class WaterHeater(HomeAccessory): ( ATTR_MAX_TEMP, ATTR_MIN_TEMP, + ATTR_OPERATION_LIST, ) ) self._unit = self.hass.config.units.temperature_unit @@ -752,6 +760,20 @@ class WaterHeater(HomeAccessory): assert state min_temp, max_temp = self.get_temperature_range(state) + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + operation_list = state.attributes.get(ATTR_OPERATION_LIST) or [] + self._supports_on_off = bool(features & WaterHeaterEntityFeature.ON_OFF) + self._supports_operation_mode = bool( + features & WaterHeaterEntityFeature.OPERATION_MODE + ) + self._off_mode_available = self._supports_on_off or ( + self._supports_operation_mode and STATE_OFF in operation_list + ) + + valid_modes = dict(HC_HOMEKIT_VALID_MODES_WATER_HEATER) + if self._off_mode_available: + valid_modes["Off"] = HC_HEAT_COOL_OFF + serv_thermostat = self.add_preload_service(SERV_THERMOSTAT) self.char_current_heat_cool = serv_thermostat.configure_char( @@ -761,7 +783,7 @@ class WaterHeater(HomeAccessory): CHAR_TARGET_HEATING_COOLING, value=1, setter_callback=self.set_heat_cool, - valid_values=HC_HOMEKIT_VALID_MODES_WATER_HEATER, + valid_values=valid_modes, ) self.char_current_temp = serv_thermostat.configure_char( @@ -795,8 +817,48 @@ class WaterHeater(HomeAccessory): def set_heat_cool(self, value: int) -> None: """Change operation mode to value if call came from HomeKit.""" _LOGGER.debug("%s: Set heat-cool to %d", self.entity_id, value) - if HC_HOMEKIT_TO_HASS[value] != HVACMode.HEAT: - self.char_target_heat_cool.set_value(1) # Heat + params: dict[str, Any] = {ATTR_ENTITY_ID: self.entity_id} + if value == HC_HEAT_COOL_OFF: + if self._supports_on_off: + self.async_call_service( + WATER_HEATER_DOMAIN, SERVICE_TURN_OFF, params, "off" + ) + elif self._off_mode_available and self._supports_operation_mode: + params[ATTR_OPERATION_MODE] = STATE_OFF + self.async_call_service( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + params, + STATE_OFF, + ) + else: + self.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) + elif value == HC_HEAT_COOL_HEAT: + if self._supports_on_off: + self.async_call_service( + WATER_HEATER_DOMAIN, SERVICE_TURN_ON, params, "on" + ) + elif self._off_mode_available and self._supports_operation_mode: + state = self.hass.states.get(self.entity_id) + if not state: + return + current_operation_mode = state.attributes.get(ATTR_OPERATION_MODE) + if current_operation_mode and current_operation_mode != STATE_OFF: + # Already in a non-off operation mode; do not change it. + return + operation_list = state.attributes.get(ATTR_OPERATION_LIST) or [] + for mode in operation_list: + if mode != STATE_OFF: + params[ATTR_OPERATION_MODE] = mode + self.async_call_service( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + params, + mode, + ) + break + else: + self.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) def set_target_temperature(self, value: float) -> None: """Set target temperature to value if call came from HomeKit.""" @@ -829,7 +891,12 @@ class WaterHeater(HomeAccessory): # Update target operation mode if new_state.state: - self.char_target_heat_cool.set_value(1) # Heat + if new_state.state == STATE_OFF and self._off_mode_available: + self.char_target_heat_cool.set_value(HC_HEAT_COOL_OFF) + self.char_current_heat_cool.set_value(HC_HEAT_COOL_OFF) + else: + self.char_target_heat_cool.set_value(HC_HEAT_COOL_HEAT) + self.char_current_heat_cool.set_value(HC_HEAT_COOL_HEAT) def _get_temperature_range_from_state( 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..5ba07b7944a 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 @@ -625,10 +623,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 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..e0955c4c0de 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 diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 3b15e69b149..1b853f9eef6 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 diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 6a6252b434c..c38cc139284 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 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..613646e3924 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 diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index 6195e61af3f..600c8849895 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 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..74a182539a7 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 ( diff --git a/homeassistant/components/homekit_controller/event.py b/homeassistant/components/homekit_controller/event.py index b90d561d60d..e6d1b26b35b 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 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..ef9821f89e4 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 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..8309ab62b6f 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 ( 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..89ff8547a92 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 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/switch.py b/homeassistant/components/homekit_controller/switch.py index c24a4edf545..88024b610ff 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 @@ -70,6 +68,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/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..e923534e12e 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 ( diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index 9a153eb0aa8..ecf0c615098 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 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..5774456a81a 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 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..707288ef0c0 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 _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,61 @@ 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) + @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..24bd75ea6f3 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -1,7 +1,5 @@ """Support for HomematicIP Cloud binary sensor.""" -from __future__ import annotations - from typing import Any from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState @@ -179,7 +177,7 @@ class HomematicipCloudConnectionSensor(HomematicipGenericEntity, BinarySensorEnt def __init__(self, hap: HomematicipHAP) -> None: """Initialize the cloud connection sensor.""" - super().__init__(hap, hap.home) + super().__init__(hap, hap.home, feature_id="cloud_connection") @property def name(self) -> str: @@ -245,10 +243,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 +268,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 +277,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 +294,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 +306,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 @@ -319,6 +329,10 @@ class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOTION + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the motion detector.""" + super().__init__(hap, device, feature_id="motion") + @property def is_on(self) -> bool: """Return true if motion is detected.""" @@ -334,7 +348,7 @@ 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: @@ -359,7 +373,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: @@ -379,6 +393,10 @@ class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.PRESENCE + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the presence detector.""" + super().__init__(hap, device, feature_id="presence") + @property def is_on(self) -> bool: """Return true if presence is detected.""" @@ -390,6 +408,10 @@ class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.SMOKE + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the smoke detector.""" + super().__init__(hap, device, feature_id="smoke") + @property def is_on(self) -> bool: """Return true if smoke is detected.""" @@ -410,7 +432,9 @@ class HomematicipSmokeDetectorChamberDegraded( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize smoke detector chamber health sensor.""" - super().__init__(hap, device, post="Chamber Degraded") + super().__init__( + hap, device, post="Chamber Degraded", feature_id="chamber_degraded" + ) @property def is_on(self) -> bool: @@ -423,6 +447,10 @@ class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOISTURE + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the water detector.""" + super().__init__(hap, device, feature_id="water") + @property def is_on(self) -> bool: """Return true, if moisture or waterlevel is detected.""" @@ -434,7 +462,7 @@ class HomematicipStormSensor(HomematicipGenericEntity, BinarySensorEntity): 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: @@ -454,7 +482,7 @@ class HomematicipRainSensor(HomematicipGenericEntity, BinarySensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize rain sensor.""" - super().__init__(hap, device, "Raining") + super().__init__(hap, device, "Raining", feature_id="rain") @property def is_on(self) -> bool: @@ -469,7 +497,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: @@ -495,7 +523,7 @@ class HomematicipBatterySensor(HomematicipGenericEntity, BinarySensorEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize battery sensor.""" - super().__init__(hap, device, post="Battery") + super().__init__(hap, device, post="Battery", channel=0, feature_id="battery") @property def is_on(self) -> bool: @@ -512,7 +540,7 @@ class HomematicipPluggableMainsFailureSurveillanceSensor( def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize pluggable mains failure surveillance sensor.""" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="mains_failure") @property def is_on(self) -> bool: @@ -525,10 +553,16 @@ class HomematicipSecurityZoneSensorGroup(HomematicipGenericEntity, BinarySensorE _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 +612,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..cca774644f1 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 @@ -83,7 +81,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 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..f9ff0877e8b 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.""" @@ -310,7 +325,9 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): 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..8be96f69469 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 @@ -86,6 +84,8 @@ class HomematicipGenericEntity(Entity): channel: int | None = None, is_multi_channel: bool | None = False, channel_real_index: int | None = None, + *, + feature_id: str, ) -> None: """Initialize the generic entity.""" self._hap = hap @@ -101,6 +101,7 @@ class HomematicipGenericEntity(Entity): # 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): @@ -237,11 +238,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: diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py index f98b078ab73..a0502f72f54 100644 --- a/homeassistant/components/homematicip_cloud/event.py +++ b/homeassistant/components/homematicip_cloud/event.py @@ -85,6 +85,7 @@ class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity): post=description.key, channel=channel, is_multi_channel=False, + feature_id="doorbell", ) self.entity_description = description diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 7d213e71e07..76b783531b5 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 diff --git a/homeassistant/components/homematicip_cloud/helpers.py b/homeassistant/components/homematicip_cloud/helpers.py index 041b6eb54d8..b3cfae75494 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 diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index 6affad00b3f..7449e819bf5 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 @@ -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..dd6722f0087 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.10.0"] } diff --git a/homeassistant/components/homematicip_cloud/migration.py b/homeassistant/components/homematicip_cloud/migration.py new file mode 100644 index 00000000000..e8dbb80bc80 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/migration.py @@ -0,0 +1,231 @@ +"""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 _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) + """ + # Find the matching class name (longest first) + matched_class: str | None = None + for class_name in _SORTED_CLASS_NAMES: + prefix = class_name + "_" + if old_unique_id.startswith(prefix): + matched_class = class_name + break + + 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..4749a9dc4b2 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 @@ -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,9 @@ class HmipSmokeDetectorSensor(HomematicipGenericEntity, SensorEntity): description: HmipSmokeDetectorSensorDescription, ) -> None: """Initialize the smoke detector sensor.""" - super().__init__(hap, device, post=description.key) + super().__init__( + hap, device, post=description.key, feature_id="smoke_detector_sensor" + ) self.entity_description = description self._sensor_unique_id = f"{device.id}_{description.key}" @@ -1047,7 +1097,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 +1123,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/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 59216c904a4..7eba7fca469 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 @@ -143,7 +145,7 @@ class HomematicipGroupSwitch(HomematicipGenericEntity, SwitchEntity): 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..46e695ee820 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 @@ -72,7 +70,7 @@ class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity): def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the weather sensor.""" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="weather") @property def name(self) -> str: @@ -125,7 +123,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..fcea7c6da3a 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 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/config_flow.py b/homeassistant/components/homewizard/config_flow.py index b348475caed..b08e86faf73 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 diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index 9da8ed4f972..dee3212d8db 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -1,7 +1,5 @@ """Constants for the Homewizard integration.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index e87381c5fa9..c6cee510d89 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -1,7 +1,5 @@ """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 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..eb77ec8166b 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -1,7 +1,5 @@ """Base entity for the HomeWizard integration.""" -from __future__ import annotations - from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity 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/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..d47d19e051c 100644 --- a/homeassistant/components/homewizard/repairs.py +++ b/homeassistant/components/homewizard/repairs.py @@ -1,7 +1,5 @@ """Repairs for HomeWizard integration.""" -from __future__ import annotations - from homeassistant import data_entry_flow from homeassistant.components.repairs import RepairsFlow from homeassistant.config_entries import ConfigEntry 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 5a88154fc2e..287d4b416bd 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 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..f9531e759a7 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 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..cb8b61062d5 100644 --- a/homeassistant/components/homeworks/config_flow.py +++ b/homeassistant/components/homeworks/config_flow.py @@ -1,7 +1,5 @@ """Lutron Homeworks Series 4 and 8 config flow.""" -from __future__ import annotations - from functools import partial import logging from typing import Any 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..7806d107c6b 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 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/strings.json b/homeassistant/components/honeywell/strings.json index 2c8e4397b8d..b057030ff30 100644 --- a/homeassistant/components/honeywell/strings.json +++ b/homeassistant/components/honeywell/strings.json @@ -77,7 +77,7 @@ "message": "Honeywell could not stop hold mode" }, "switch_failed_off": { - "message": "Honeywell could turn off emergency heat mode." + "message": "Honeywell could not turn off emergency heat mode." }, "switch_failed_on": { "message": "Honeywell could not set system mode to emergency heat mode." 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..f0540226070 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/config_flow.py @@ -0,0 +1,59 @@ +"""Config flow for the Honeywell String Lights integration.""" + +from typing import Any + +from rf_protocols import RadioFrequencyCommand +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 +from .light import COMMANDS + + +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( + COMMANDS.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") + + 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..7aee30c11fc --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/light.py @@ -0,0 +1,63 @@ +"""Light platform for Honeywell String Lights.""" + +from typing import Any + +from rf_protocols import get_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 + +COMMANDS = get_codes("honeywell/string_lights") + + +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 COMMANDS.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..62d65edb28e --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/manifest.json @@ -0,0 +1,12 @@ +{ + "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", + "requirements": ["rf-protocols==2.2.0"] +} 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..a5c995ace08 --- /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": "No radio frequency transmitter supports 433.92 MHz OOK transmissions. Please add a compatible transmitter first.", + "no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first." + }, + "step": { + "user": { + "data": { + "transmitter": "Radio frequency transmitter" + }, + "data_description": { + "transmitter": "The radio frequency transmitter used to control the Honeywell String Lights." + } + } + } + } +} diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py index d1b733ab84a..5ce6aa404af 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 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..3d22fb7a10e --- /dev/null +++ b/homeassistant/components/hr_energy_qube/binary_sensor.py @@ -0,0 +1,291 @@ +"""Binary sensor platform for Qube Heat Pump.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from python_qube_heatpump.models import QubeState + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory + +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[[QubeState], bool | None] + + +BINARY_SENSOR_TYPES: tuple[QubeBinarySensorEntityDescription, ...] = ( + # Outputs + QubeBinarySensorEntityDescription( + key="source_pump", + translation_key="source_pump", + value_fn=lambda data: data.dout_srcpmp_val, + ), + QubeBinarySensorEntityDescription( + key="user_pump", + translation_key="user_pump", + value_fn=lambda data: data.dout_usrpmp_val, + ), + QubeBinarySensorEntityDescription( + key="four_way_valve", + translation_key="four_way_valve", + value_fn=lambda data: data.dout_fourwayvlv_val, + ), + QubeBinarySensorEntityDescription( + key="cooling_output", + translation_key="cooling_output", + value_fn=lambda data: data.dout_cooling_val, + ), + QubeBinarySensorEntityDescription( + key="three_way_valve", + translation_key="three_way_valve", + value_fn=lambda data: data.dout_threewayvlv_val, + ), + QubeBinarySensorEntityDescription( + key="buffer_pump", + translation_key="buffer_pump", + value_fn=lambda data: data.dout_bufferpmp_val, + ), + QubeBinarySensorEntityDescription( + key="heater_step_1", + translation_key="heater_step_1", + value_fn=lambda data: data.dout_heaterstep1_val, + ), + QubeBinarySensorEntityDescription( + key="heater_step_2", + translation_key="heater_step_2", + value_fn=lambda data: data.dout_heaterstep2_val, + ), + QubeBinarySensorEntityDescription( + key="heater_step_3", + translation_key="heater_step_3", + value_fn=lambda data: data.dout_heaterstep3_val, + ), + # System status + QubeBinarySensorEntityDescription( + key="keypad", + translation_key="keypad", + value_fn=lambda data: data.keybonoff, + ), + QubeBinarySensorEntityDescription( + key="day_mode", + translation_key="day_mode", + value_fn=lambda data: data.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.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.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.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.al_underfloorsafety_active, + ), + QubeBinarySensorEntityDescription( + key="alarm_flow", + translation_key="alarm_flow", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.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.usralrms, + ), + QubeBinarySensorEntityDescription( + key="alarm_cooling", + translation_key="alarm_cooling", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.coolingalrms, + ), + QubeBinarySensorEntityDescription( + key="alarm_heating", + translation_key="alarm_heating", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.heatingalrms, + ), + QubeBinarySensorEntityDescription( + key="alarm_working_hours", + translation_key="alarm_working_hours", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.alarmmng_al_workinghour, + ), + QubeBinarySensorEntityDescription( + key="alarm_source", + translation_key="alarm_source", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.srsalrm, + ), + QubeBinarySensorEntityDescription( + key="alarm_global", + translation_key="alarm_global", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.glbal, + ), + QubeBinarySensorEntityDescription( + key="alarm_compressor", + translation_key="alarm_compressor", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.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.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.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.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.en_dhwpid, + ), + # Demand signals + QubeBinarySensorEntityDescription( + key="plant_demand", + translation_key="plant_demand", + value_fn=lambda data: data.plantdemand, + ), + QubeBinarySensorEntityDescription( + key="external_demand", + translation_key="external_demand", + value_fn=lambda data: data.id_demand, + ), + QubeBinarySensorEntityDescription( + key="thermostat_demand", + translation_key="thermostat_demand", + value_fn=lambda data: data.thermostatdemand, + ), + # Digital inputs + QubeBinarySensorEntityDescription( + key="summer_mode", + translation_key="summer_mode", + value_fn=lambda data: data.id_summerwinter, + ), + QubeBinarySensorEntityDescription( + key="dewpoint", + translation_key="dewpoint", + value_fn=lambda data: data.dewpoint, + ), + QubeBinarySensorEntityDescription( + key="booster_security", + translation_key="booster_security", + value_fn=lambda data: data.boostersecurity, + ), + QubeBinarySensorEntityDescription( + key="source_flow", + translation_key="source_flow", + value_fn=lambda data: data.srcflw, + ), + QubeBinarySensorEntityDescription( + key="anti_legionella", + translation_key="anti_legionella", + value_fn=lambda data: data.req_antileg_1, + ), + # Energy + QubeBinarySensorEntityDescription( + key="pv_surplus", + translation_key="pv_surplus", + value_fn=lambda data: data.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..1f9ea9a820c 100644 --- a/homeassistant/components/hr_energy_qube/const.py +++ b/homeassistant/components/hr_energy_qube/const.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform DOMAIN = "hr_energy_qube" -PLATFORMS = (Platform.SENSOR,) +PLATFORMS = (Platform.BINARY_SENSOR, Platform.SENSOR) 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..5740add8770 100644 --- a/homeassistant/components/hr_energy_qube/coordinator.py +++ b/homeassistant/components/hr_energy_qube/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Qube Heat Pump.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import TYPE_CHECKING 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/sensor.py b/homeassistant/components/hr_energy_qube/sensor.py index 7ddd2feffe1..791018f8254 100644 --- a/homeassistant/components/hr_energy_qube/sensor.py +++ b/homeassistant/components/hr_energy_qube/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Qube Heat Pump.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import TYPE_CHECKING diff --git a/homeassistant/components/hr_energy_qube/strings.json b/homeassistant/components/hr_energy_qube/strings.json index e2b87ed5c74..72aec767512 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" diff --git a/homeassistant/components/html5/__init__.py b/homeassistant/components/html5/__init__.py index 225379dfa1a..5cd10a98a27 100644 --- a/homeassistant/components/html5/__init__.py +++ b/homeassistant/components/html5/__init__.py @@ -4,14 +4,23 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.EVENT, Platform.NOTIFY] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the HTML5 services.""" + + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up HTML5 from a config entry.""" hass.async_create_task( 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 a256241b066..cc0aeb282c7 100644 --- a/homeassistant/components/html5/const.py +++ b/homeassistant/components/html5/const.py @@ -11,5 +11,18 @@ ATTR_VAPID_EMAIL = "vapid_email" REGISTRATIONS_FILE = "html5_push_registrations.conf" ATTR_ACTION = "action" +ATTR_ACTIONS = "actions" +ATTR_BADGE = "badge" ATTR_DATA = "data" +ATTR_DIR = "dir" +ATTR_ICON = "icon" +ATTR_IMAGE = "image" +ATTR_LANG = "lang" +ATTR_RENOTIFY = "renotify" +ATTR_REQUIRE_INTERACTION = "require_interaction" +ATTR_SILENT = "silent" ATTR_TAG = "tag" +ATTR_TIMESTAMP = "timestamp" +ATTR_TTL = "ttl" +ATTR_URGENCY = "urgency" +ATTR_VIBRATE = "vibrate" 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/icons.json b/homeassistant/components/html5/icons.json index 4b3fd84b69f..a01f7cf3b72 100644 --- a/homeassistant/components/html5/icons.json +++ b/homeassistant/components/html5/icons.json @@ -9,6 +9,12 @@ "services": { "dismiss": { "service": "mdi:bell-off" + }, + "dismiss_message": { + "service": "mdi:comment-remove" + }, + "send_message": { + "service": "mdi:comment-arrow-right" } } } diff --git a/homeassistant/components/html5/issue.py b/homeassistant/components/html5/issue.py new file mode 100644 index 00000000000..66c11f5c742 --- /dev/null +++ b/homeassistant/components/html5/issue.py @@ -0,0 +1,54 @@ +"""Issues for HTML5 integration.""" + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.util import slugify + +from .const import DOMAIN + + +@callback +def deprecated_notify_action_call( + hass: HomeAssistant, target: list[str] | None +) -> None: + """Deprecated action call.""" + + action = ( + f"notify.html5_{slugify(target[0])}" + if target and len(target) == 1 + else "notify.html5" + ) + + async_create_issue( + hass, + DOMAIN, + f"deprecated_notify_action_{action}", + breaks_in_ha_version="2026.11.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_notify_action", + translation_placeholders={ + "action": action, + "new_action_1": "notify.send_message", + "new_action_2": "html5.send_message", + }, + ) + + +@callback +def deprecated_dismiss_action_call(hass: HomeAssistant) -> None: + """Deprecated action call.""" + + async_create_issue( + hass, + DOMAIN, + "deprecated_dismiss_action", + breaks_in_ha_version="2026.11.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_dismiss_action", + translation_placeholders={ + "action": "html5.dismiss", + "new_action": "html5.dismiss_message", + }, + ) 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 21d57f7fb8d..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 @@ -47,7 +45,11 @@ from homeassistant.util.json import load_json_object from .const import ( ATTR_ACTION, + ATTR_ACTIONS, + ATTR_REQUIRE_INTERACTION, ATTR_TAG, + ATTR_TIMESTAMP, + ATTR_TTL, ATTR_VAPID_EMAIL, ATTR_VAPID_PRV_KEY, ATTR_VAPID_PUB_KEY, @@ -56,6 +58,7 @@ from .const import ( SERVICE_DISMISS, ) from .entity import HTML5Entity, Registration +from .issue import deprecated_dismiss_action_call, deprecated_notify_action_call _LOGGER = logging.getLogger(__name__) @@ -69,13 +72,11 @@ ATTR_AUTH = "auth" ATTR_P256DH = "p256dh" ATTR_EXPIRATIONTIME = "expirationTime" -ATTR_ACTIONS = "actions" ATTR_TYPE = "type" ATTR_URL = "url" ATTR_DISMISS = "dismiss" ATTR_PRIORITY = "priority" DEFAULT_PRIORITY = "normal" -ATTR_TTL = "ttl" DEFAULT_TTL = 86400 DEFAULT_BADGE = "/static/images/notification-badge.png" @@ -457,6 +458,9 @@ class HTML5NotificationService(BaseNotificationService): This method must be run in the event loop. """ + + deprecated_dismiss_action_call(self.hass) + data: dict[str, Any] | None = kwargs.get(ATTR_DATA) tag: str = data.get(ATTR_TAG, "") if data else "" payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}} @@ -465,6 +469,9 @@ class HTML5NotificationService(BaseNotificationService): async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" + + deprecated_notify_action_call(self.hass, kwargs.get(ATTR_TARGET)) + tag = str(uuid.uuid4()) payload: dict[str, Any] = { "badge": DEFAULT_BADGE, @@ -605,32 +612,58 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity): _key = "device" async def async_send_message(self, message: str, title: str | None = None) -> None: - """Send a message to a device.""" - timestamp = int(time.time()) - tag = str(uuid.uuid4()) + """Send a message to a device via notify.send_message action.""" + await self._webpush( + title=title or ATTR_TITLE_DEFAULT, + message=message, + badge=DEFAULT_BADGE, + icon=DEFAULT_ICON, + ) - payload: dict[str, Any] = { - "badge": DEFAULT_BADGE, - "body": message, - "icon": DEFAULT_ICON, - ATTR_TAG: tag, - ATTR_TITLE: title or ATTR_TITLE_DEFAULT, - "timestamp": timestamp * 1000, - ATTR_DATA: { - ATTR_JWT: add_jwt( - timestamp, - self.target, - tag, - self.registration["subscription"]["keys"]["auth"], - ) - }, - } + async def send_push_notification(self, **kwargs: Any) -> None: + """Send a message to a device via html5.send_message action.""" + await self._webpush(**kwargs) + self._async_record_notification() + + async def dismiss_notification(self, tag: str = "") -> None: + """Dismiss a message via html5.dismiss_message action.""" + await self._webpush(dismiss=True, tag=tag) + self._async_record_notification() + + async def _webpush( + self, + message: str | None = None, + timestamp: datetime | None = None, + ttl: timedelta | None = None, + urgency: str | None = None, + **kwargs: Any, + ) -> None: + """Shared internal helper to push messages.""" + payload: dict[str, Any] = kwargs + + if message is not None: + payload["body"] = message + + payload.setdefault(ATTR_TAG, str(uuid.uuid4())) + ts = int(timestamp.timestamp()) if timestamp else int(time.time()) + payload[ATTR_TIMESTAMP] = ts * 1000 + + if ATTR_REQUIRE_INTERACTION in payload: + payload["requireInteraction"] = payload.pop(ATTR_REQUIRE_INTERACTION) + + payload.setdefault(ATTR_DATA, {}) + payload[ATTR_DATA][ATTR_JWT] = add_jwt( + ts, + self.target, + payload[ATTR_TAG], + self.registration["subscription"]["keys"]["auth"], + ) endpoint = urlparse(self.registration["subscription"]["endpoint"]) vapid_claims = { "sub": f"mailto:{self.config_entry.data[ATTR_VAPID_EMAIL]}", "aud": f"{endpoint.scheme}://{endpoint.netloc}", - "exp": timestamp + (VAPID_CLAIM_VALID_HOURS * 60 * 60), + "exp": ts + (VAPID_CLAIM_VALID_HOURS * 60 * 60), } try: @@ -639,6 +672,8 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity): json.dumps(payload), self.config_entry.data[ATTR_VAPID_PRV_KEY], vapid_claims, + ttl=int(ttl.total_seconds()) if ttl is not None else DEFAULT_TTL, + headers={"Urgency": urgency} if urgency else None, aiohttp_session=self.session, ) cast(ClientResponse, response).raise_for_status() diff --git a/homeassistant/components/html5/services.py b/homeassistant/components/html5/services.py new file mode 100644 index 00000000000..06b6e5f92a0 --- /dev/null +++ b/homeassistant/components/html5/services.py @@ -0,0 +1,95 @@ +"""Service registration for HTML5 integration.""" + +import voluptuous as vol + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_MESSAGE, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + DOMAIN as NOTIFY_DOMAIN, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import ( + ATTR_ACTION, + ATTR_ACTIONS, + ATTR_BADGE, + ATTR_DIR, + ATTR_ICON, + ATTR_IMAGE, + ATTR_LANG, + ATTR_RENOTIFY, + ATTR_REQUIRE_INTERACTION, + ATTR_SILENT, + ATTR_TAG, + ATTR_TIMESTAMP, + ATTR_TTL, + ATTR_URGENCY, + ATTR_VIBRATE, + DOMAIN, +) + +SERVICE_SEND_MESSAGE = "send_message" +SERVICE_DISMISS_MESSAGE = "dismiss_message" + +SERVICE_SEND_MESSAGE_SCHEMA = cv.make_entity_service_schema( + { + vol.Required(ATTR_TITLE, default=ATTR_TITLE_DEFAULT): cv.string, + vol.Optional(ATTR_MESSAGE): cv.string, + vol.Optional(ATTR_DIR): vol.In({"auto", "ltr", "rtl"}), + vol.Optional(ATTR_ICON): cv.string, + vol.Optional(ATTR_BADGE): cv.string, + vol.Optional(ATTR_IMAGE): cv.string, + vol.Optional(ATTR_TAG): cv.string, + vol.Exclusive(ATTR_VIBRATE, "silent_xor_vibrate"): vol.All( + cv.ensure_list, + [vol.All(vol.Coerce(int), vol.Range(min=0))], + ), + vol.Optional(ATTR_TIMESTAMP): cv.datetime, + vol.Optional(ATTR_LANG): cv.language, + vol.Exclusive(ATTR_SILENT, "silent_xor_vibrate"): cv.boolean, + vol.Optional(ATTR_RENOTIFY): cv.boolean, + vol.Optional(ATTR_REQUIRE_INTERACTION): cv.boolean, + vol.Optional(ATTR_URGENCY): vol.In({"normal", "high", "low"}), + vol.Optional(ATTR_TTL): vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(ATTR_ACTIONS): vol.All( + cv.ensure_list, + [ + { + vol.Required(ATTR_ACTION): cv.string, + vol.Required(ATTR_TITLE): cv.string, + vol.Optional(ATTR_ICON): cv.string, + } + ], + ), + vol.Optional(ATTR_DATA): dict, + } +) + +SERVICE_DISMISS_MESSAGE_SCHEMA = cv.make_entity_service_schema( + {vol.Optional(ATTR_TAG): cv.string} +) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for HTML5 integration.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SEND_MESSAGE, + entity_domain=NOTIFY_DOMAIN, + schema=SERVICE_SEND_MESSAGE_SCHEMA, + func="send_push_notification", + ) + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_DISMISS_MESSAGE, + entity_domain=NOTIFY_DOMAIN, + schema=SERVICE_DISMISS_MESSAGE_SCHEMA, + func="dismiss_notification", + ) diff --git a/homeassistant/components/html5/services.yaml b/homeassistant/components/html5/services.yaml index 929eb5a2dc1..9ed88b2ba71 100644 --- a/homeassistant/components/html5/services.yaml +++ b/homeassistant/components/html5/services.yaml @@ -8,3 +8,144 @@ dismiss: example: '{ "tag": "tagname" }' selector: object: +send_message: + target: + entity: + domain: notify + integration: html5 + fields: + title: + required: true + selector: + text: + example: Home Assistant + default: Home Assistant + message: + required: false + selector: + text: + multiline: true + example: Hello World + icon: + required: false + selector: + text: + type: url + example: /static/icons/favicon-192x192.png + badge: + required: false + selector: + text: + type: url + example: /static/images/notification-badge.png + image: + required: false + selector: + text: + type: url + example: /static/images/image.jpg + tag: &tag + required: false + selector: + text: + example: message-group-1 + actions: + selector: + object: + label_field: "action" + description_field: "title" + multiple: true + translation_key: actions + fields: + action: + required: true + selector: + text: + title: + required: true + selector: + text: + icon: + selector: + text: + type: url + example: '[{"action": "test-action", "title": "🆗 Click here!", "icon": "/images/action-1-128x128.png"}]' + dir: + required: false + selector: + select: + options: + - auto + - ltr + - rtl + mode: dropdown + translation_key: dir + example: auto + renotify: + required: false + selector: + constant: + value: true + label: "" + example: true + silent: + required: false + selector: + constant: + value: true + label: "" + example: true + require_interaction: + required: false + selector: + constant: + value: true + label: "" + example: true + vibrate: + required: false + selector: + text: + multiple: true + type: number + suffix: ms + example: "[125,75,125,275,200,275,125,75,125,275,200,600,200,600]" + lang: + required: false + selector: + language: + example: es-419 + timestamp: + required: false + selector: + datetime: + example: "1970-01-01 00:00:00" + ttl: + required: false + selector: + duration: + enable_day: true + example: "{'days': 28}" + urgency: + required: false + selector: + select: + options: + - low + - normal + - high + mode: dropdown + translation_key: urgency + example: normal + data: + required: false + selector: + object: + example: "{'customKey': 'customValue'}" +dismiss_message: + target: + entity: + domain: notify + integration: html5 + fields: + tag: *tag diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index c419451dc2c..3dea89e6ead 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -32,7 +32,9 @@ "received": "Received" } }, - "tag": { "name": "Tag" } + "tag": { + "name": "[%key:component::html5::services::send_message::fields::tag::name%]" + } } } } @@ -48,6 +50,48 @@ "message": "Sending notification to {target} failed due to a request error" } }, + "issues": { + "deprecated_dismiss_action": { + "description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `{new_action}` action instead.", + "title": "[%key:component::html5::issues::deprecated_notify_action::title%]" + }, + "deprecated_notify_action": { + "description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `{new_action_1}` or `{new_action_2}` actions instead.", + "title": "Detected use of deprecated action {action}" + } + }, + "selector": { + "actions": { + "fields": { + "action": { + "description": "The identifier of the action. This will be sent back to Home Assistant when the user clicks the button.", + "name": "Action identifier" + }, + "icon": { + "description": "URL of an image displayed as the icon for this button.", + "name": "Icon" + }, + "title": { + "description": "The label of the button displayed to the user.", + "name": "Title" + } + } + }, + "dir": { + "options": { + "auto": "[%key:common::state::auto%]", + "ltr": "Left-to-right", + "rtl": "Right-to-left" + } + }, + "urgency": { + "options": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]", + "normal": "[%key:common::state::normal%]" + } + } + }, "services": { "dismiss": { "description": "Dismisses an HTML5 notification.", @@ -62,6 +106,90 @@ } }, "name": "Dismiss" + }, + "dismiss_message": { + "description": "Dismisses one or more HTML5 notifications.", + "fields": { + "tag": { + "description": "The tag of the notifications to dismiss. If not specified, all notifications will be dismissed.", + "name": "[%key:component::html5::services::send_message::fields::tag::name%]" + } + }, + "name": "Dismiss message" + }, + "send_message": { + "description": "Sends a message via HTML5 Push Notifications", + "fields": { + "actions": { + "description": "Adds action buttons to the notification. When the user clicks a button, an event is sent back to Home Assistant. Amount of actions supported may vary between platforms.", + "name": "Action buttons" + }, + "badge": { + "description": "URL or relative path of a small image to replace the browser icon on mobile platforms. Maximum size is 96px by 96px", + "name": "Badge" + }, + "data": { + "description": "Additional custom key-value pairs to include in the payload of the push message. This can be used to include extra information that can be accessed in the notification events.", + "name": "Extra data" + }, + "dir": { + "description": "The direction of the notification's text. Adopts the browser's language setting behavior by default.", + "name": "Text direction" + }, + "icon": { + "description": "URL or relative path of an image to display as the main icon in the notification. Maximum size is 320px by 320px.", + "name": "Icon" + }, + "image": { + "description": "URL or relative path of a larger image to display in the main body of the notification. Experimental support, may not be displayed on all platforms.", + "name": "Image" + }, + "lang": { + "description": "The language of the notification's content.", + "name": "Language" + }, + "message": { + "description": "The message body of the notification.", + "name": "Message" + }, + "renotify": { + "description": "If enabled, the user will be alerted again (sound/vibration) when a notification with the same tag replaces a previous one.", + "name": "Renotify" + }, + "require_interaction": { + "description": "If enabled, the notification will remain active until the user clicks or dismisses it, rather than automatically closing after a few seconds. This provides the same behavior on desktop as on mobile platforms.", + "name": "Require interaction" + }, + "silent": { + "description": "If enabled, the notification will not play sounds or trigger vibration, regardless of the device's notification settings.", + "name": "Silent" + }, + "tag": { + "description": "The identifier of the notification. Sending a new notification with the same tag will replace the existing one. If not specified, a unique tag will be generated for each notification.", + "name": "Tag" + }, + "timestamp": { + "description": "The timestamp of the notification. By default, it uses the time when the notification is sent.", + "name": "Timestamp" + }, + "title": { + "description": "Title for your notification message.", + "name": "Title" + }, + "ttl": { + "description": "Specifies how long the push service should retain the message if the user's browser or device is offline. After this period, the notification expires. A value of 0 means the notification is discarded immediately if the target is not connected. Defaults to 1 day.", + "name": "Time to live" + }, + "urgency": { + "description": "Whether the push service should try to deliver the notification immediately or defer it in accordance with the user's power saving preferences.", + "name": "Urgency" + }, + "vibrate": { + "description": "A vibration pattern to run with the notification. An array of integers representing alternating periods of vibration and silence in milliseconds. For example, [200, 100, 200] would vibrate for 200ms, pause for 100ms, then vibrate for another 200ms.", + "name": "Vibration pattern" + } + }, + "name": "Send message" } } } 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..f4a4a1b55ed 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 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..6f90cc80e8a 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,7 +411,7 @@ 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: @@ -439,15 +434,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 +451,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 +459,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 +504,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..b6abc37da60 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() 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..48ce8a6a79d 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 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..ad94799bb16 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) diff --git a/homeassistant/components/huawei_lte/quality_scale.yaml b/homeassistant/components/huawei_lte/quality_scale.yaml index 57fce90fdd6..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 @@ -81,5 +81,4 @@ rules: inject-websession: status: exempt comment: Underlying huawei-lte-api does not use aiohttp or httpx, so this does not apply. - strict-typing: - status: done + strict-typing: done 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..38b66934455 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, @@ -795,11 +791,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)): 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/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..684cbd6ff2b 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 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/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/manifest.json b/homeassistant/components/hue/manifest.json index 0adc0dfc3b3..58272b5b1a5 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -10,6 +10,6 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiohue"], - "requirements": ["aiohue==4.8.0"], + "requirements": ["aiohue==4.8.1"], "zeroconf": ["_hue._tcp.local."] } 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/switch.py b/homeassistant/components/hue/switch.py index 33dfe02dd49..7400e192399 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 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..f716176cbd8 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 diff --git a/homeassistant/components/hue/v1/sensor_base.py b/homeassistant/components/hue/v1/sensor_base.py index 9cb836386e0..9ecdf2121f8 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 diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index ec80ad1f4bb..2bfb53aa553 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 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..0c58286e13e 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 diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index e472009286d..02eb11e0643 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 diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 8a1168d992e..d43dabf1100 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 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..64021bceec2 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 diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index e22d2c09f43..543b1b2169d 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 diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index 0c92b0c8b3e..047613ac955 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 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..406bdd88b8c 100644 --- a/homeassistant/components/humidifier/condition.py +++ b/homeassistant/components/humidifier/condition.py @@ -5,7 +5,7 @@ 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.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 +13,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 @@ -46,6 +46,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 +93,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 82ae8b57436..9838f9a7967 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -1,19 +1,20 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted humidifiers.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted humidifiers to trigger on.", - "trigger_behavior_name": "Behavior" + "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" }, "conditions": { "is_drying": { "description": "Tests if one or more humidifiers are drying.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" } }, "name": "Humidifier is drying" @@ -22,8 +23,10 @@ "description": "Tests if one or more humidifiers are humidifying.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" } }, "name": "Humidifier is humidifying" @@ -32,9 +35,11 @@ "description": "Tests if one or more humidifiers are set to a specific mode.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "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" @@ -46,8 +51,10 @@ "description": "Tests if one or more humidifiers are off.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" } }, "name": "Humidifier is off" @@ -56,8 +63,10 @@ "description": "Tests if one or more humidifiers are on.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" } }, "name": "Humidifier is on" @@ -66,11 +75,12 @@ "description": "Tests the target humidity of one or more humidifiers.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::condition_behavior_description%]", "name": "[%key:component::humidifier::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::humidifier::common::condition_threshold_description%]", "name": "[%key:component::humidifier::common::condition_threshold_name%]" } }, @@ -164,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.", @@ -219,9 +214,11 @@ "description": "Triggers after the operation mode of one or more humidifiers changes.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "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" @@ -233,8 +230,10 @@ "description": "Triggers after one or more humidifiers start drying.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::trigger_for_name%]" } }, "name": "Humidifier started drying" @@ -243,8 +242,10 @@ "description": "Triggers after one or more humidifiers start humidifying.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::trigger_for_name%]" } }, "name": "Humidifier started humidifying" @@ -253,8 +254,10 @@ "description": "Triggers after one or more humidifiers turn off.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::trigger_for_name%]" } }, "name": "Humidifier turned off" @@ -263,8 +266,10 @@ "description": "Triggers after one or more humidifiers turn on.", "fields": { "behavior": { - "description": "[%key:component::humidifier::common::trigger_behavior_description%]", "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/triggers.yaml b/homeassistant/components/humidifier/triggers.yaml index 12072ab71eb..d7ca86262dd 100644 --- a/homeassistant/components/humidifier/triggers.yaml +++ b/homeassistant/components/humidifier/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any 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 06836f05dce..7f51d21f4f4 100644 --- a/homeassistant/components/humidity/strings.json +++ b/homeassistant/components/humidity/strings.json @@ -1,53 +1,35 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "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": { "is_value": { "description": "Tests if a relative humidity value is above a threshold, below a threshold, or in a range of values.", "fields": { "behavior": { - "description": "[%key:component::humidity::common::condition_behavior_description%]", "name": "[%key:component::humidity::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::humidity::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::humidity::common::condition_threshold_description%]", "name": "[%key:component::humidity::common::condition_threshold_name%]" } }, "name": "Relative humidity" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Humidity", "triggers": { "changed": { "description": "Triggers after one or more relative humidity values change.", "fields": { "threshold": { - "description": "[%key:component::humidity::common::trigger_threshold_changed_description%]", "name": "[%key:component::humidity::common::trigger_threshold_name%]" } }, @@ -57,11 +39,12 @@ "description": "Triggers after one or more relative humidity values cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::humidity::common::trigger_behavior_description%]", "name": "[%key:component::humidity::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::humidity::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::humidity::common::trigger_threshold_crossed_description%]", "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..e37cde2b992 100644 --- a/homeassistant/components/humidity/triggers.yaml +++ b/homeassistant/components/humidity/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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..91876030fde 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 diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index b78d0be0865..1c4a6139c68 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 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/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/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 1d1619762df..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 @@ -184,10 +182,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): ) def _should_poll(self) -> bool: - """Return True if at least one mower is connected and at least one is not OFF.""" - return any(mower.metadata.connected for mower in self.data.values()) and any( - mower.mower.state != MowerStates.OFF for mower in self.data.values() - ) + """Return True if at least one mower is not OFF.""" + return any(mower.mower.state != MowerStates.OFF for mower in self.data.values()) async def _pong_watchdog(self) -> None: """Watchdog to check for pong messages.""" 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..c02f8e3d924 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -1,7 +1,5 @@ """Platform for Husqvarna Automower base entity.""" -from __future__ import annotations - import asyncio from collections.abc import Callable, Coroutine import functools diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index aa77ae2f7b7..51b0eee72f6 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.5"] } diff --git a/homeassistant/components/husqvarna_automower_ble/__init__.py b/homeassistant/components/husqvarna_automower_ble/__init__.py index 89de3336440..be4f4e119d4 100644 --- a/homeassistant/components/husqvarna_automower_ble/__init__.py +++ b/homeassistant/components/husqvarna_automower_ble/__init__.py @@ -1,7 +1,5 @@ """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 diff --git a/homeassistant/components/husqvarna_automower_ble/config_flow.py b/homeassistant/components/husqvarna_automower_ble/config_flow.py index d36b89f2d13..51fbc25310e 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 != 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( 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/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 fec8eea47a1..6422498628a 100644 --- a/homeassistant/components/huum/quality_scale.yaml +++ b/homeassistant/components/huum/quality_scale.yaml @@ -47,11 +47,11 @@ rules: discovery: todo discovery-update-info: todo docs-data-update: done - docs-examples: todo + docs-examples: done docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: done dynamic-devices: status: exempt @@ -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..16227dc5b2b 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform for hvv_departures.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index 63d457bf302..771f63d5164 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -1,7 +1,5 @@ """Config flow for HVV integration.""" -from __future__ import annotations - import logging from typing import Any 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..c257ccec0cf 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 diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index 58153d43634..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 @@ -69,6 +67,10 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): @callback def _handle_coordinator_update(self) -> None: """Get the latest data and updates the state.""" + # Guard against updates arriving after the controller has been removed + # but before the entity has been unsubscribed from the coordinator. + if self.controller.id not in self.coordinator.data.controllers: + return self.controller = self.coordinator.data.controllers[self.controller.id] self._update_attrs() super()._handle_coordinator_update() 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 60a53193acc..910cfbae191 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -1,6 +1,4 @@ -"""The Hyperion component.""" - -from __future__ import annotations +"""The Hyperion integration.""" import asyncio from collections.abc import Callable diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index bd96c9667ad..143ac01d180 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -1,6 +1,4 @@ -"""Switch platform for Hyperion.""" - -from __future__ import annotations +"""Camera platform for Hyperion.""" import asyncio import base64 diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 1ef53ad2951..f17608c5c3e 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 diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 4cf0ed0f5e2..0c1363cbbe4 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 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..65286ed3a73 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 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/manifest.json b/homeassistant/components/hypontech/manifest.json index 0f417f491c1..54cefce5476 100644 --- a/homeassistant/components/hypontech/manifest.json +++ b/homeassistant/components/hypontech/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["hyponcloud==0.9.0"] + "requirements": ["hyponcloud==0.9.3"] } 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..ecd8f5f7f25 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,19 +15,25 @@ 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 _LOGGER = logging.getLogger(__name__) @@ -54,6 +57,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,11 +78,12 @@ 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}" @@ -86,24 +91,47 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) -> try: systems = await aqualink.get_systems() + except AqualinkServiceUnauthorizedException as auth_exception: + await aqualink.close() + raise ConfigEntryAuthFailed( + "Invalid credentials for iAquaLink" + ) from auth_exception except AqualinkServiceException as svc_exception: await aqualink.close() raise ConfigEntryNotReady( f"Error while attempting to retrieve systems list: {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 AqualinkServiceUnauthorizedException as auth_exception: + await aqualink.close() + raise ConfigEntryAuthFailed( + "Invalid credentials for iAquaLink" + ) from auth_exception except AqualinkServiceException as svc_exception: await aqualink.close() raise ConfigEntryNotReady( @@ -151,32 +179,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 +199,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..0ad57584310 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" diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py index b828c25c945..03a48f4932f 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 - +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.""" 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, 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/coordinator.py b/homeassistant/components/iaqualink/coordinator.py new file mode 100644 index 00000000000..0285915bcd9 --- /dev/null +++ b/homeassistant/components/iaqualink/coordinator.py @@ -0,0 +1,49 @@ +"""Data update coordinator for iaqualink.""" + +import logging +from typing import Any + +import httpx +from iaqualink.exception import ( + AqualinkServiceException, + 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 + +_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.""" + 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 (AqualinkServiceException, httpx.HTTPError) as err: + raise UpdateFailed( + f"Unable to update iAquaLink system {self.system.serial}: {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..f008e81a782 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 diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json index fea0531264a..d977a4c87f6 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": "bronze", + "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..cefaaca753c --- /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: todo + 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: todo + + # 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..556e5f223c5 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"): diff --git a/homeassistant/components/iaqualink/utils.py b/homeassistant/components/iaqualink/utils.py index 62d2d4d2e93..2bce5fba6c0 100644 --- a/homeassistant/components/iaqualink/utils.py +++ b/homeassistant/components/iaqualink/utils.py @@ -1,7 +1,5 @@ """Utility functions for Aqualink devices.""" -from __future__ import annotations - from collections.abc import Awaitable import httpx 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/coordinator.py b/homeassistant/components/ibeacon/coordinator.py index 4f232220440..7cc478de359 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 diff --git a/homeassistant/components/ibeacon/device_tracker.py b/homeassistant/components/ibeacon/device_tracker.py index 0d2ee0137cc..558bd183880 100644 --- a/homeassistant/components/ibeacon/device_tracker.py +++ b/homeassistant/components/ibeacon/device_tracker.py @@ -1,7 +1,5 @@ """Support for tracking iBeacon devices.""" -from __future__ import annotations - from ibeacon_ble import iBeaconAdvertisement from homeassistant.components.device_tracker import SourceType 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..45a51c246a3 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 diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index d6b60d6da98..630bc3d94ac 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 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..84af908c7bd 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 diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py index aa832fdfe48..3a3f3cdb7f7 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 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/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/config_flow.py b/homeassistant/components/idrive_e2/config_flow.py index 9395383a702..0f54a03fb68 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 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..23820438a5e 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 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/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/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/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 5ed11170df0..59bd195116f 100644 --- a/homeassistant/components/illuminance/strings.json +++ b/homeassistant/components/illuminance/strings.json @@ -1,22 +1,21 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "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": { "is_detected": { "description": "Tests if light is currently detected.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::condition_behavior_description%]", "name": "[%key:component::illuminance::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::illuminance::common::condition_for_name%]" } }, "name": "Light is detected" @@ -25,8 +24,10 @@ "description": "Tests if light is currently not detected.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::condition_behavior_description%]", "name": "[%key:component::illuminance::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::illuminance::common::condition_for_name%]" } }, "name": "Light is not detected" @@ -35,39 +36,24 @@ "description": "Tests the illuminance value.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::condition_behavior_description%]", "name": "[%key:component::illuminance::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::illuminance::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::illuminance::common::condition_threshold_description%]", "name": "[%key:component::illuminance::common::condition_threshold_name%]" } }, "name": "Illuminance" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Illuminance", "triggers": { "changed": { "description": "Triggers after one or more illuminance values change.", "fields": { "threshold": { - "description": "[%key:component::illuminance::common::trigger_threshold_changed_description%]", "name": "[%key:component::illuminance::common::trigger_threshold_name%]" } }, @@ -77,8 +63,10 @@ "description": "Triggers after one or more light sensors stop detecting light.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::trigger_behavior_description%]", "name": "[%key:component::illuminance::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::illuminance::common::trigger_for_name%]" } }, "name": "Light cleared" @@ -87,11 +75,12 @@ "description": "Triggers after one or more illuminance values cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::trigger_behavior_description%]", "name": "[%key:component::illuminance::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::illuminance::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::illuminance::common::trigger_threshold_crossed_description%]", "name": "[%key:component::illuminance::common::trigger_threshold_name%]" } }, @@ -101,8 +90,10 @@ "description": "Triggers after one or more light sensors start detecting light.", "fields": { "behavior": { - "description": "[%key:component::illuminance::common::trigger_behavior_description%]", "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..b76bd6fe34f 100644 --- a/homeassistant/components/illuminance/triggers.yaml +++ b/homeassistant/components/illuminance/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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..f833d324001 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -1,7 +1,5 @@ """The image integration.""" -from __future__ import annotations - import asyncio import collections from contextlib import suppress 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..838fa288d26 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] @@ -153,8 +146,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 +243,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..4ef788535ee 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 @@ -494,6 +492,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 +526,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 +543,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/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..dc496d5a7a5 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 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..c394679056e 100644 --- a/homeassistant/components/imgw_pib/icons.json +++ b/homeassistant/components/imgw_pib/icons.json @@ -4,6 +4,9 @@ "hydrological_alert": { "default": "mdi:alert-octagon-outline" }, + "ice_phenomena": { + "default": "mdi:snowflake" + }, "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..998cd98bbeb 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.1.1"] } diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 170736d8f6c..972f19a3110 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", diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index e746d66a945..15a14e4d6f2 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -59,6 +59,9 @@ } } }, + "ice_phenomena": { + "name": "Ice phenomena" + }, "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..ade1a5627eb 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.0"] } diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index e37172cb5e1..81ac70f0b79 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -1,7 +1,5 @@ """Immich as a media source.""" -from __future__ import annotations - from logging import getLogger from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse @@ -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") ] # -------------------------------------------------------- @@ -239,6 +237,12 @@ class ImmichMediaSource(MediaSource): ) 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 ImmichError: + return [] ret: list[BrowseMediaSource] = [] for asset in assets: 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/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/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..9452912927f 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 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..b1daaf7dfeb 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -1,7 +1,5 @@ """Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway.""" -from __future__ import annotations - from aiohttp import ClientResponseError from incomfortclient import InvalidGateway, InvalidHeaterList diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py index 356cee82e57..b8b79a8960d 100644 --- a/homeassistant/components/incomfort/binary_sensor.py +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -1,7 +1,5 @@ """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 diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index c10cbe5be5b..cbfa8bf8844 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -1,7 +1,5 @@ """Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway.""" -from __future__ import annotations - from typing import Any from incomfortclient import Heater as InComfortHeater, Room as InComfortRoom diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py index 027c3ad4691..86d3aa787c5 100644 --- a/homeassistant/components/incomfort/config_flow.py +++ b/homeassistant/components/incomfort/config_flow.py @@ -1,7 +1,5 @@ """Config flow support for Intergas InComfort integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/incomfort/diagnostics.py b/homeassistant/components/incomfort/diagnostics.py index 4d7af14eac7..29ba123bf3f 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 diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json index ad904f31c77..b87e82266cd 100644 --- a/homeassistant/components/incomfort/manifest.json +++ b/homeassistant/components/incomfort/manifest.json @@ -12,5 +12,5 @@ "iot_class": "local_polling", "loggers": ["incomfortclient"], "quality_scale": "platinum", - "requirements": ["incomfort-client==0.6.12"] + "requirements": ["incomfort-client==0.7.0"] } diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index 21db7125c30..dab375f04b1 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -1,7 +1,5 @@ """Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway.""" -from __future__ import annotations - from dataclasses import dataclass from typing import Any diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json index 6b3ef1aa45e..8c331741a99 100644 --- a/homeassistant/components/incomfort/strings.json +++ b/homeassistant/components/incomfort/strings.json @@ -92,11 +92,13 @@ "central_heating": "Central heating", "central_heating_low": "Central heating low", "central_heating_rf": "Central heating rf", + "central_heating_wait": "Central heating waiting", "cv_temperature_too_high_e1": "Temperature too high", "flame_detection_fault_e6": "Flame detection fault", "frost": "Frost protection", "gas_valve_relay_faulty_e29": "Gas valve relay faulty", "gas_valve_relay_faulty_e30": "[%key:component::incomfort::entity::water_heater::boiler::state::gas_valve_relay_faulty_e29%]", + "hp_error_recovery": "Heat pump error recovery", "incorrect_fan_speed_e8": "Incorrect fan speed", "no_flame_signal_e4": "No flame signal", "off": "[%key:common::state::off%]", @@ -120,6 +122,7 @@ "service": "Service", "shortcut_outside_sensor_temperature_e27": "Shortcut outside temperature sensor", "standby": "[%key:common::state::standby%]", + "starting_ch": "Starting central heating", "tapwater": "Tap water", "tapwater_int": "Tap water internal", "unknown": "Unknown" diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 2a2c7cc47da..d7f3782edec 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -1,7 +1,5 @@ """Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/indevolt/__init__.py b/homeassistant/components/indevolt/__init__.py index 7a4341d602b..cb63bb07445 100644 --- a/homeassistant/components/indevolt/__init__.py +++ b/homeassistant/components/indevolt/__init__.py @@ -1,19 +1,23 @@ """Home Assistant integration for indevolt device.""" -from __future__ import annotations - from homeassistant.const import Platform from homeassistant.core import HomeAssistant +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_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool: @@ -29,6 +33,14 @@ 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).""" 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..109a9488f6b --- /dev/null +++ b/homeassistant/components/indevolt/binary_sensor.py @@ -0,0 +1,154 @@ +"""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 = 1 + off_value: int = 0 + generation: tuple[int, ...] = (1, 2) + + +BINARY_SENSORS: Final = ( + # Electricity Meter Status + IndevoltBinarySensorEntityDescription( + key=IndevoltGrid.METER_CONNECTED, + translation_key="meter_connected", + on_value=1000, + off_value=1001, + 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/const.py b/homeassistant/components/indevolt/const.py index 3b469282a64..95fb7fbece1 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,112 @@ 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, + 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, + IndevoltConfig.READ_DISCHARGE_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, ], 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_GEN2, + 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_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, + 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, + 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, ], } diff --git a/homeassistant/components/indevolt/coordinator.py b/homeassistant/components/indevolt/coordinator.py index 19320eec544..12381aa519a 100644 --- a/homeassistant/components/indevolt/coordinator.py +++ b/homeassistant/components/indevolt/coordinator.py @@ -1,13 +1,16 @@ """Home Assistant integration for Indevolt device.""" -from __future__ import annotations - from datetime import timedelta 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 @@ -21,11 +24,6 @@ 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, ) @@ -70,19 +68,17 @@ 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: - raise ConfigEntryNotReady( - f"Device config retrieval timed out: {err}" - ) from err + except (ClientError, OSError) as err: + raise ConfigEntryNotReady(f"Device config retrieval failed: {err}") from err # Cache device information device_data = config_data.get("device", {}) @@ -95,23 +91,23 @@ class IndevoltCoordinator(DataUpdateCoordinator[dict[str, Any]]): try: return await self.api.fetch_data(sensor_keys) - except TimeOutException as err: - raise UpdateFailed(f"Device update timed out: {err}") from err + except (ClientError, OSError) as err: + raise UpdateFailed(f"Device update failed: {err}") from err 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: + except TimeoutError as err: raise DeviceTimeoutError(f"Device push timed out: {err}") from err - except (ClientError, ConnectionError, OSError) as err: + except (ClientError, OSError) as err: raise DeviceConnectionError(f"Device push failed: {err}") from err 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 +117,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", @@ -130,7 +126,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) + success = await self.async_push_data( + IndevoltConfig.WRITE_ENERGY_MODE, target_mode + ) except (DeviceTimeoutError, DeviceConnectionError) as err: raise HomeAssistantError( translation_domain=DOMAIN, @@ -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..8047701c8b7 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,13 +15,13 @@ 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, } diff --git a/homeassistant/components/indevolt/icons.json b/homeassistant/components/indevolt/icons.json new file mode 100644 index 00000000000..13499365b25 --- /dev/null +++ b/homeassistant/components/indevolt/icons.json @@ -0,0 +1,10 @@ +{ + "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..442a641dfeb 100644 --- a/homeassistant/components/indevolt/manifest.json +++ b/homeassistant/components/indevolt/manifest.json @@ -6,6 +6,6 @@ "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": "silver", + "requirements": ["indevolt-api==1.7.1"] } diff --git a/homeassistant/components/indevolt/number.py b/homeassistant/components/indevolt/number.py index 0831e9b9657..5458245f6d9 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, @@ -27,30 +27,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 +58,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 +70,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, diff --git a/homeassistant/components/indevolt/quality_scale.yaml b/homeassistant/components/indevolt/quality_scale.yaml index 9e948fd9365..713f32f91f6 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 @@ -52,20 +46,13 @@ rules: discovery: status: exempt comment: Integration does not support network discovery - docs-data-update: - status: todo - docs-examples: - status: todo - docs-known-limitations: - status: todo - docs-supported-devices: - status: todo - docs-supported-functions: - status: todo - docs-troubleshooting: - status: todo - docs-use-cases: - status: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo dynamic-devices: status: exempt comment: Integration represents a single device, not a hub with multiple devices @@ -73,10 +60,8 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: - status: todo - icon-translations: - status: todo + exception-translations: todo + icon-translations: todo reconfiguration-flow: done repair-issues: status: exempt @@ -88,5 +73,4 @@ rules: # Platinum async-dependency: done inject-websession: done - strict-typing: - status: todo + strict-typing: todo diff --git a/homeassistant/components/indevolt/select.py b/homeassistant/components/indevolt/select.py index 2850ae2da52..31b6f09b9d3 100644 --- a/homeassistant/components/indevolt/select.py +++ b/homeassistant/components/indevolt/select.py @@ -1,10 +1,10 @@ """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 @@ -23,23 +23,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], ), ) diff --git a/homeassistant/components/indevolt/sensor.py b/homeassistant/components/indevolt/sensor.py index 75040bf8e7e..aad367ce79c 100644 --- a/homeassistant/components/indevolt/sensor.py +++ b/homeassistant/components/indevolt/sensor.py @@ -3,6 +3,14 @@ from dataclasses import dataclass, field from typing import Final +from indevolt_api import ( + IndevoltBattery, + IndevoltConfig, + IndevoltGrid, + IndevoltSolar, + IndevoltSystem, +) + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -34,21 +42,25 @@ 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) 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,38 +71,36 @@ SENSORS: Final = ( device_class=SensorDeviceClass.ENUM, ), IndevoltSensorEntityDescription( - key="142", - generation=[2], + key=IndevoltBattery.RATED_CAPACITY_GEN2, + generation=(2,), 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, ), IndevoltSensorEntityDescription( - key="2101", + 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, + generation=(2,), translation_key="bypass_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, @@ -98,63 +108,63 @@ SENSORS: Final = ( ), # 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, + generation=(2,), 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, + generation=(2,), 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, + generation=(2,), 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, + generation=(2,), 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, + generation=(2,), 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, + generation=(2,), 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, + generation=(2,), translation_key="battery_total_discharging_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, device_class=SensorDeviceClass.ENERGY, @@ -162,16 +172,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 +189,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 +198,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,20 +208,20 @@ 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, @@ -219,21 +229,21 @@ SENSORS: Final = ( ), # 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 +251,8 @@ SENSORS: Final = ( state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="1632", - generation=[2], + key=IndevoltSolar.DC_INPUT_CURRENT_1, + generation=(2,), translation_key="dc_input_current_1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -250,8 +260,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1600", - generation=[2], + key=IndevoltSolar.DC_INPUT_VOLTAGE_1, + generation=(2,), translation_key="dc_input_voltage_1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -259,7 +269,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 +277,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1633", - generation=[2], + key=IndevoltSolar.DC_INPUT_CURRENT_2, + generation=(2,), translation_key="dc_input_current_2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, @@ -276,8 +286,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1601", - generation=[2], + key=IndevoltSolar.DC_INPUT_VOLTAGE_2, + generation=(2,), translation_key="dc_input_voltage_2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, @@ -285,7 +295,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 +303,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 +312,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 +321,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 +330,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 +339,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 +348,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 +358,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 +411,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 +421,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 +431,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 +441,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 +451,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 +462,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 +472,8 @@ SENSORS: Final = ( entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9030", - generation=[2], + 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 +482,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 +492,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 +502,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 +552,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, @@ -513,8 +563,8 @@ SENSORS: Final = ( ), # 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 +573,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 +583,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 +593,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 +603,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 +613,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 +624,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 +634,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 +644,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 +654,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 +664,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 +674,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, @@ -637,11 +687,41 @@ SENSORS: Final = ( # Sensors per battery pack (SN, SOC, Temperature, Voltage, Current) 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_VOLTAGE, + IndevoltBattery.PACK_1_CURRENT, + ), + ( + IndevoltBattery.PACK_2_SERIAL_NUMBER, + IndevoltBattery.PACK_2_SOC, + IndevoltBattery.PACK_2_TEMPERATURE, + IndevoltBattery.PACK_2_VOLTAGE, + IndevoltBattery.PACK_2_CURRENT, + ), + ( + IndevoltBattery.PACK_3_SERIAL_NUMBER, + IndevoltBattery.PACK_3_SOC, + IndevoltBattery.PACK_3_TEMPERATURE, + IndevoltBattery.PACK_3_VOLTAGE, + IndevoltBattery.PACK_3_CURRENT, + ), + ( + IndevoltBattery.PACK_4_SERIAL_NUMBER, + IndevoltBattery.PACK_4_SOC, + IndevoltBattery.PACK_4_TEMPERATURE, + IndevoltBattery.PACK_4_VOLTAGE, + IndevoltBattery.PACK_4_CURRENT, + ), + ( + IndevoltBattery.PACK_5_SERIAL_NUMBER, + IndevoltBattery.PACK_5_SOC, + IndevoltBattery.PACK_5_TEMPERATURE, + IndevoltBattery.PACK_5_VOLTAGE, + IndevoltBattery.PACK_5_CURRENT, + ), ] diff --git a/homeassistant/components/indevolt/services.py b/homeassistant/components/indevolt/services.py new file mode 100644 index 00000000000..b6e506948b4 --- /dev/null +++ b/homeassistant/components/indevolt/services.py @@ -0,0 +1,216 @@ +"""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 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..83bba1627af 100644 --- a/homeassistant/components/indevolt/strings.json +++ b/homeassistant/components/indevolt/strings.json @@ -35,6 +35,32 @@ } }, "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" @@ -223,6 +249,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": { @@ -238,6 +267,9 @@ "grid_voltage": { "name": "Grid voltage" }, + "inverter_temperature": { + "name": "Inverter temperature" + }, "main_current": { "name": "Main current" }, @@ -307,6 +339,59 @@ }, "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}%)" + } + }, + "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..203f8ba4195 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, @@ -29,16 +29,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 +46,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, ), ) 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..19cad4fe5eb 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 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/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..6d6be0e8ffa 100644 --- a/homeassistant/components/infrared/__init__.py +++ b/homeassistant/components/infrared/__init__.py @@ -1,7 +1,5 @@ """Provides functionality to interact with infrared devices.""" -from __future__ import annotations - from abc import abstractmethod from datetime import timedelta import logging diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index d81f5ecffa7..284a4a19c7d 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==2.1.0"] } 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..72af4b656c0 100644 --- a/homeassistant/components/inkbird/config_flow.py +++ b/homeassistant/components/inkbird/config_flow.py @@ -1,7 +1,5 @@ """Config flow for inkbird ble integration.""" -from __future__ import annotations - from typing import Any from inkbird_ble import INKBIRDBluetoothDeviceData as DeviceData diff --git a/homeassistant/components/inkbird/coordinator.py b/homeassistant/components/inkbird/coordinator.py index fbacedf7e0f..bf6de65110b 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 diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py index c7d80e9bc9f..ba81789b45e 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 ( 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..3957432df31 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 diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index fb739490233..8c88e3d8faa 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 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..a6ce9978658 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 diff --git a/homeassistant/components/input_select/reproduce_state.py b/homeassistant/components/input_select/reproduce_state.py index b451f8c3f09..de0db6fa1f8 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 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..de0177a4f97 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") 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/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..98758750020 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 diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index 8a325152120..3f2f693b198 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -1,7 +1,5 @@ """The IntelliFire integration.""" -from __future__ import annotations - import asyncio from intellifire4py import UnifiedFireplace @@ -143,7 +141,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") 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..ef7d261bf5d 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 ( diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index e58a5e46559..95b1ee0c1d8 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 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..0cb1ea6c0ac 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 diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index 37188cb5a2e..7374f45ff7a 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 diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 8d58a0dd45b..5c5cb438b33 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -1,7 +1,5 @@ """Handle intents with scripts.""" -from __future__ import annotations - import logging from typing import Any, TypedDict @@ -78,8 +76,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] = {} diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index c0ad603ba17..35a2121827d 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 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/ios/__init__.py b/homeassistant/components/ios/__init__.py index ef141a28475..af7da2be4ee 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=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import datetime from http import HTTPStatus diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index cf70a97f52a..f7e3af9b7e2 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 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..8994977bd14 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 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..feca7e33f54 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 diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index b30e019798c..7a6d484031d 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 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..6ae59f6a75d 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 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..bed284f8d9c 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 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/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..908891a5b04 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 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..bd487ffbdba 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 diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index e9056bc9abc..fcae9ca8789 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 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..07975b3baf1 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 diff --git a/homeassistant/components/iskra/config_flow.py b/homeassistant/components/iskra/config_flow.py index b67b9ba3839..55d50721bd6 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 diff --git a/homeassistant/components/iskra/sensor.py b/homeassistant/components/iskra/sensor.py index 10aa5555249..e61eda73796 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 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..2a1ee0c09b5 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 diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 8bd7e5904b0..af76b50e807 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 diff --git a/homeassistant/components/israel_rail/coordinator.py b/homeassistant/components/israel_rail/coordinator.py index 190ed938790..0621202dbcb 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 @@ -83,6 +82,7 @@ class IsraelRailDataUpdateCoordinator(DataUpdateCoordinator[list[DataConnection] 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), 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..1cfdb97242e 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 @@ -67,6 +67,15 @@ SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = ( translation_key="train_number", value_fn=lambda data_connection: data_connection.train_number, ), + IsraelRailSensorEntityDescription( + key="departure_delay", + translation_key="departure_delay", + 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, + ), ) diff --git a/homeassistant/components/israel_rail/strings.json b/homeassistant/components/israel_rail/strings.json index 3b16015fe34..e7380c80245 100644 --- a/homeassistant/components/israel_rail/strings.json +++ b/homeassistant/components/israel_rail/strings.json @@ -28,6 +28,9 @@ "departure2": { "name": "Departure +2" }, + "departure_delay": { + "name": "Departure delay" + }, "platform": { "name": "Platform" }, 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..021f953b2b3 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 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 e39850d6c51..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 @@ -23,7 +21,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool ista = PyEcotrendIsta( entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD], - _LOGGER, ) coordinator = IstaCoordinator(hass, entry, ista) diff --git a/homeassistant/components/ista_ecotrend/config_flow.py b/homeassistant/components/ista_ecotrend/config_flow.py index 3eb7c4720b2..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 @@ -51,7 +49,6 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): ista = PyEcotrendIsta( user_input[CONF_EMAIL], user_input[CONF_PASSWORD], - _LOGGER, ) try: await self.hass.async_add_executor_job(ista.login) @@ -102,7 +99,6 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN): ista = PyEcotrendIsta( user_input[CONF_EMAIL], user_input[CONF_PASSWORD], - _LOGGER, ) def get_consumption_units() -> set[str]: diff --git a/homeassistant/components/ista_ecotrend/coordinator.py b/homeassistant/components/ista_ecotrend/coordinator.py index 13167b9d06c..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 @@ -94,10 +92,8 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]): result = self.ista.get_consumption_unit_details() return { - consumption_unit: next( - details - for details in result["consumptionUnits"] - if details["id"] == consumption_unit - ) + consumption_unit: details for consumption_unit in self.ista.get_uuids() + for details in result["consumptionUnits"] + if details["id"] == consumption_unit } 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..4e7e18935d4 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 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..ce8e6bb9274 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 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..4306b05b832 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Universal Devices ISY/IoX integration.""" -from __future__ import annotations - import asyncio from collections.abc import Mapping import logging diff --git a/homeassistant/components/isy994/const.py b/homeassistant/components/isy994/const.py index b43385a0e5d..9a0acf73601 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, @@ -431,7 +432,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 +445,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 +654,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..139a17846d6 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 ( 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..1ba973ca1e1 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 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..d34afac96f5 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.5.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..cfe71ba1c62 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 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..5c36f608067 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.""" @@ -240,8 +327,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 diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 3f31b2e5730..d66060937b7 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 diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index f44613317c5..185f8ac398c 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 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/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/climate.py b/homeassistant/components/izone/climate.py index f0fd93834e1..41e22dd12b8 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 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..d497ba17bf1 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 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..84f8f883ad4 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 @@ -168,7 +166,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: diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index 7dc0745a51e..3be69b42b61 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 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..de370e1707a 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 diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 34189d4ab09..eeae6a27dd0 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 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/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index f52e14537b3..faded9f1b7d 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 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..4403ae07807 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 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..033e965107e 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 @@ -144,7 +147,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 c2b1243a993..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.3"] + "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..0f62f096090 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 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/manifest.json b/homeassistant/components/kaleidescape/manifest.json index 6996b70bd2d..699cbe8dc0d 100644 --- a/homeassistant/components/kaleidescape/manifest.json +++ b/homeassistant/components/kaleidescape/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/kaleidescape", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pykaleidescape==1.1.4"], + "requirements": ["pykaleidescape==1.1.5"], "ssdp": [ { "deviceType": "schemas-upnp-org:device:Basic:1", 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/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..7ac7f164a61 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=hass-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..aa62a5bfaf5 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, diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index 7a5eae0eec6..d5909e507d6 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 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..e1592dded8a 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 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..dc1c60746b8 --- /dev/null +++ b/homeassistant/components/kiosker/__init__.py @@ -0,0 +1,27 @@ +"""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.SENSOR] + + +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/config_flow.py b/homeassistant/components/kiosker/config_flow.py new file mode 100644 index 00000000000..6f7797ad1ce --- /dev/null +++ b/homeassistant/components/kiosker/config_flow.py @@ -0,0 +1,198 @@ +"""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_HOST, CONF_SSL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import CONF_API_TOKEN, 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..40cc8b9d033 --- /dev/null +++ b/homeassistant/components/kiosker/const.py @@ -0,0 +1,12 @@ +"""Constants for the Kiosker integration.""" + +DOMAIN = "kiosker" + +# Configuration keys +CONF_API_TOKEN = "api_token" + +# Default values +PORT = 8081 +POLL_INTERVAL = 15 +DEFAULT_SSL = False +DEFAULT_SSL_VERIFY = False diff --git a/homeassistant/components/kiosker/coordinator.py b/homeassistant/components/kiosker/coordinator.py new file mode 100644 index 00000000000..b029eba3b69 --- /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_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 CONF_API_TOKEN, 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..749a8d7d02e --- /dev/null +++ b/homeassistant/components/kiosker/icons.json @@ -0,0 +1,32 @@ +{ + "entity": { + "binary_sensor": { + "blackout_state": { + "default": "mdi:monitor", + "state": { + "on": "mdi:monitor-off" + } + }, + "screensaver_state": { + "default": "mdi:power-sleep", + "state": { + "off": "mdi:monitor-shimmer" + } + } + }, + "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" + } + } + } +} 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..5cd897381e7 --- /dev/null +++ b/homeassistant/components/kiosker/strings.json @@ -0,0 +1,71 @@ +{ + "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" + } + }, + "sensor": { + "ambient_light": { + "name": "Ambient light" + }, + "last_interaction": { + "name": "Last interaction" + }, + "last_motion": { + "name": "Last motion" + } + } + } +} 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..6441130f6c8 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -4,8 +4,6 @@ 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 @@ -62,6 +60,7 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.LAWN_MOWER, Platform.LOCK, Platform.NOTIFY, + Platform.RADIO_FREQUENCY, Platform.SENSOR, Platform.SWITCH, Platform.WEATHER, 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..5fa493b2f16 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 diff --git a/homeassistant/components/kitchen_sink/const.py b/homeassistant/components/kitchen_sink/const.py index bce291bd5d6..6e506f7c334 100644 --- a/homeassistant/components/kitchen_sink/const.py +++ b/homeassistant/components/kitchen_sink/const.py @@ -1,7 +1,5 @@ """Constants for the Kitchen Sink integration.""" -from __future__ import annotations - from collections.abc import Callable from homeassistant.util.hass_dict import HassKey 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/fan.py b/homeassistant/components/kitchen_sink/fan.py index db02da6930c..8ece03d90c2 100644 --- a/homeassistant/components/kitchen_sink/fan.py +++ b/homeassistant/components/kitchen_sink/fan.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake infrared fan entity.""" -from __future__ import annotations - from typing import Any import infrared_protocols 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..1ebae1f532d 100644 --- a/homeassistant/components/kitchen_sink/infrared.py +++ b/homeassistant/components/kitchen_sink/infrared.py @@ -1,7 +1,5 @@ """Demo platform that offers a fake infrared entity.""" -from __future__ import annotations - import infrared_protocols from homeassistant.components import persistent_notification @@ -55,11 +53,6 @@ class DemoInfrared(InfraredEntity): async def async_send_command(self, command: infrared_protocols.Command) -> None: """Send an IR command.""" - timings = [ - interval - for timing in command.get_raw_timings() - for interval in (timing.high_us, -timing.low_us) - ] persistent_notification.async_create( - self.hass, str(timings), title="Infrared Command" + self.hass, str(command.get_raw_timings()), title="Infrared Command" ) 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..5bcf9d17a8c 100644 --- a/homeassistant/components/kitchen_sink/repairs.py +++ b/homeassistant/components/kitchen_sink/repairs.py @@ -1,7 +1,5 @@ """Repairs platform for the demo integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant import data_entry_flow 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/strings.json b/homeassistant/components/kitchen_sink/strings.json index 15305d711b2..e369e0942bd 100644 --- a/homeassistant/components/kitchen_sink/strings.json +++ b/homeassistant/components/kitchen_sink/strings.json @@ -72,7 +72,7 @@ "cold_tea": { "fix_flow": { "abort": { - "not_tea_time": "Can not re-heat the tea at this time" + "not_tea_time": "Cannot reheat the tea at this time" }, "step": {} }, 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..77e0016b8ec 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 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..a545e33c74f 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]) @@ -157,6 +158,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 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..ac9280e3f18 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 diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index bcfcbd18a2a..e65184d79a3 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 diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 8c27353e7f0..c957f5cc5b2 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 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..74e72a684c4 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 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/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..a006ee0cbb2 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 diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 21db22d515c..ab02476f78a 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 diff --git a/homeassistant/components/knx/knx_module.py b/homeassistant/components/knx/knx_module.py index 105817a04d5..28dc7e422cd 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: diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 4ceeda7b932..edc4f983d38 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 diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index c0a838b48c0..2fb9d53ee0d 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.4.30.60856" ], "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..5373a2d2fce 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 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..00bb6b0970a 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 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..04c53bbe969 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 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..48b0cd2b99e 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 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/expose_controller.py b/homeassistant/components/knx/storage/expose_controller.py new file mode 100644 index 00000000000..524ceabaab0 --- /dev/null +++ b/homeassistant/components/knx/storage/expose_controller.py @@ -0,0 +1,164 @@ +"""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 a value is a valid expose template without coercing it to a Template object.""" + 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/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..3abb766c958 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" @@ -1171,7 +1224,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 +1242,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 +1272,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 +1286,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/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/__init__.py b/homeassistant/components/kodi/__init__.py index b5c8aed7d32..02083bb832f 100644 --- a/homeassistant/components/kodi/__init__.py +++ b/homeassistant/components/kodi/__init__.py @@ -1,4 +1,4 @@ -"""The kodi component.""" +"""The Kodi integration.""" from dataclasses import dataclass import logging 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/const.py b/homeassistant/components/kodi/const.py index 1ac439b27c3..a6d78410c6d 100644 --- a/homeassistant/components/kodi/const.py +++ b/homeassistant/components/kodi/const.py @@ -1,4 +1,4 @@ -"""Constants for the Kodi platform.""" +"""Constants for the Kodi integration.""" DOMAIN = "kodi" 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..c688b051b81 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 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..8d3a2651a82 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -1,4 +1,5 @@ """Support for Konnected devices.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import copy import hmac diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index d6bdab37a9c..2af8be9da9c 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -24,6 +24,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensors attached to a Konnected device from a config entry.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data data = hass.data[DOMAIN] device_id = config_entry.data["id"] sensors = [ diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index 7f5f4d8abd4..db864a513c4 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -1,7 +1,5 @@ """Config flow for konnected.io integration.""" -from __future__ import annotations - import asyncio import copy import logging diff --git a/homeassistant/components/konnected/panel.py b/homeassistant/components/konnected/panel.py index e2dfc6be06a..702f814a49a 100644 --- a/homeassistant/components/konnected/panel.py +++ b/homeassistant/components/konnected/panel.py @@ -1,4 +1,5 @@ """Support for Konnected devices.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio import logging diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index 155e99a7002..0e51551c3cd 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -1,7 +1,5 @@ """Support for DHT and DS18B20 sensors attached to a Konnected device.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -46,6 +44,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors attached to a Konnected device from a config entry.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data data = hass.data[DOMAIN] device_id = config_entry.data["id"] diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index 54f74f0d461..bbfad9ffd9e 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -1,4 +1,5 @@ """Support for wired switches attached to a Konnected device.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import Any 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..cc417ff19cf 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 diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 85a5cdf8fe7..76c6885a5d0 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 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..9882639608a 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() @@ -79,6 +73,8 @@ class KrakenOptionsFlowHandler(OptionsFlow): ) options = { + # Polling interval is user-configurable, which is no longer allowed + # pylint: disable-next=hass-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..0f67ac7e974 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) 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/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 3d5e3bf4ce0..8e4db370db3 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 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/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index d0221e22667..b35ff5a4832 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 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..fe5d5243182 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 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/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..b2f893be5cd 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 diff --git a/homeassistant/components/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index f7288b8a0cd..991bb66d369 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 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: @@ -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/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 4b5f249a2f1..0c6c85bac60 100644 --- a/homeassistant/components/lannouncer/notify.py +++ b/homeassistant/components/lannouncer/notify.py @@ -1,112 +1,28 @@ """Lannouncer platform for notify component.""" -from __future__ import annotations - -import logging -import socket -from typing import Any -from urllib.parse import urlencode - import voluptuous as vol -from homeassistant.components.notify import ( - ATTR_DATA, - PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, - BaseNotificationService, -) -from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.components.notify import PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType DOMAIN = "lannouncer" -ATTR_METHOD = "method" -ATTR_METHOD_DEFAULT = "speak" -ATTR_METHOD_ALLOWED = ["speak", "alarm"] - -DEFAULT_PORT = 1035 - -PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } -) - -_LOGGER = logging.getLogger(__name__) +PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -def get_service( +async def async_get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, -) -> LannouncerNotificationService: +) -> None: """Get the Lannouncer notification service.""" - - @callback - def _async_create_issue() -> None: - """Create issue for removed integration.""" - ir.async_create_issue( - hass, - DOMAIN, - "integration_removed", - is_fixable=False, - breaks_in_ha_version="2026.3.0", - severity=ir.IssueSeverity.WARNING, - translation_key="integration_removed", - ) - - hass.add_job(_async_create_issue) - - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - - return LannouncerNotificationService(hass, host, port) - - -class LannouncerNotificationService(BaseNotificationService): - """Implementation of a notification service for Lannouncer.""" - - def __init__(self, hass, host, port): - """Initialize the service.""" - self._hass = hass - self._host = host - self._port = port - - def send_message(self, message: str = "", **kwargs: Any) -> None: - """Send a message to Lannouncer.""" - data = kwargs.get(ATTR_DATA) - if data is not None and ATTR_METHOD in data: - method = data.get(ATTR_METHOD) - else: - method = ATTR_METHOD_DEFAULT - - if method not in ATTR_METHOD_ALLOWED: - _LOGGER.error("Unknown method %s", method) - return - - cmd = urlencode({method: message}) - - try: - # Open socket - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(10) - sock.connect((self._host, self._port)) - - # Send message - _LOGGER.debug("Sending message: %s", cmd) - sock.sendall(cmd.encode()) - sock.sendall(b"&@DONE@\n") - - # Check response - buffer = sock.recv(1024) - if buffer != b"LANnouncer: OK": - _LOGGER.error("Error sending data to Lannnouncer: %s", buffer.decode()) - - # Close socket - sock.close() - except socket.gaierror: - _LOGGER.error("Unable to connect to host %s", self._host) - except OSError: - _LOGGER.exception("Failed to send data to Lannnouncer") + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + ) diff --git a/homeassistant/components/lannouncer/strings.json b/homeassistant/components/lannouncer/strings.json index 63b2e86aa82..1152be3fde9 100644 --- a/homeassistant/components/lannouncer/strings.json +++ b/homeassistant/components/lannouncer/strings.json @@ -1,8 +1,8 @@ { "issues": { "integration_removed": { - "description": "The LANnouncer Android app is no longer available, so this integration has been deprecated and will be removed in a future release.\n\nTo resolve this issue:\n1. Remove the LANnouncer integration from your `configuration.yaml`.\n2. Restart the Home Assistant instance.\n\nAfter removal, this issue will disappear.", - "title": "LANnouncer integration is deprecated" + "description": "The LANnouncer integration has been removed from Home Assistant because the LANnouncer Android app is no longer available.\n\nTo resolve this issue:\n1. Remove the LANnouncer integration from your `configuration.yaml`.\n2. Restart the Home Assistant instance.\n\nAfter removal, this issue will disappear.", + "title": "LANnouncer integration has been removed" } } } 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 e882b260aeb..da56ee71cc8 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted lawn mowers.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted lawn mowers to trigger on.", - "trigger_behavior_name": "Behavior" + "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_docked": { "description": "Tests if one or more lawn mowers are docked.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is docked" @@ -20,8 +22,10 @@ "description": "Tests if one or more lawn mowers are encountering an error.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "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" @@ -30,8 +34,10 @@ "description": "Tests if one or more lawn mowers are mowing.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is mowing" @@ -40,8 +46,10 @@ "description": "Tests if one or more lawn mowers are paused.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is paused" @@ -50,8 +58,10 @@ "description": "Tests if one or more lawn mowers are returning to the dock.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::condition_behavior_description%]", "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is returning" @@ -69,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.", @@ -104,8 +99,10 @@ "description": "Triggers after one or more lawn mowers have returned to dock.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "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" @@ -114,8 +111,10 @@ "description": "Triggers after one or more lawn mowers encounter an error.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "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" @@ -124,8 +123,10 @@ "description": "Triggers after one or more lawn mowers pause mowing.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" } }, "name": "Lawn mower paused mowing" @@ -134,8 +135,10 @@ "description": "Triggers after one or more lawn mowers start mowing.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" } }, "name": "Lawn mower started mowing" @@ -144,8 +147,10 @@ "description": "Triggers after one or more lawn mowers start returning to dock.", "fields": { "behavior": { - "description": "[%key:component::lawn_mower::common::trigger_behavior_description%]", "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..296919a4ab3 100644 --- a/homeassistant/components/lawn_mower/triggers.yaml +++ b/homeassistant/components/lawn_mower/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any 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..9ed7bf3417f 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 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/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..49a72e0b750 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 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/config_flow.py b/homeassistant/components/ld2410_ble/config_flow.py index 8211be44d7a..ccc84851853 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 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/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/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..14a41952b44 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -1,7 +1,5 @@ """The LED BLE integration.""" -from __future__ import annotations - import asyncio from led_ble import LEDBLE 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 e64ef235a9f..0fb5a3b3317 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.7"] + "requirements": ["bluetooth-data-tools==1.28.4", "led-ble==1.1.8"] } 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..f5153b6d05d 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -1,7 +1,5 @@ """The LetPot integration.""" -from __future__ import annotations - import asyncio from letpot.client import LetPotClient 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..4852b340952 100644 --- a/homeassistant/components/letpot/coordinator.py +++ b/homeassistant/components/letpot/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for the LetPot integration.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/lg_infrared/__init__.py b/homeassistant/components/lg_infrared/__init__.py index d8b6e51d239..f59fd5c885f 100644 --- a/homeassistant/components/lg_infrared/__init__.py +++ b/homeassistant/components/lg_infrared/__init__.py @@ -1,7 +1,5 @@ """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 diff --git a/homeassistant/components/lg_infrared/button.py b/homeassistant/components/lg_infrared/button.py index 9c1482205e4..3f12dc1d811 100644 --- a/homeassistant/components/lg_infrared/button.py +++ b/homeassistant/components/lg_infrared/button.py @@ -1,7 +1,5 @@ """Button platform for LG IR integration.""" -from __future__ import annotations - from dataclasses import dataclass from infrared_protocols.codes.lg.tv import LGTVCode diff --git a/homeassistant/components/lg_infrared/media_player.py b/homeassistant/components/lg_infrared/media_player.py index 4985a0394b8..04b24284052 100644 --- a/homeassistant/components/lg_infrared/media_player.py +++ b/homeassistant/components/lg_infrared/media_player.py @@ -1,7 +1,5 @@ """Media player platform for LG IR integration.""" -from __future__ import annotations - from infrared_protocols.codes.lg.tv import LGTVCode from homeassistant.components.media_player import ( @@ -57,11 +55,11 @@ class LgIrTvMediaPlayer(LgIrEntity, MediaPlayerEntity): async def async_turn_on(self) -> None: """Turn on the TV.""" - await self._send_command(LGTVCode.POWER) + await self._send_command(LGTVCode.POWER_ON) async def async_turn_off(self) -> None: """Turn off the TV.""" - await self._send_command(LGTVCode.POWER) + await self._send_command(LGTVCode.POWER_OFF) async def async_volume_up(self) -> None: """Send volume up command.""" 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..913009e0b43 --- /dev/null +++ b/homeassistant/components/lg_netcast/remote.py @@ -0,0 +1,81 @@ +"""Remote control support for LG Netcast TV.""" + +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any + +from pylgnetcast import LG_COMMAND, LgNetCastClient, LgNetCastError +from requests import RequestException + +from homeassistant.components.remote import ATTR_NUM_REPEATS, 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] + + 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 _ in range(num_repeats): + try: + with self._client as client: + for lg_command in commands: + 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/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index f440e0ba4ad..72235b465f0 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,7 @@ class LGDevice(MediaPlayerEntity): """Representation of an LG soundbar device.""" _attr_should_poll = False - _attr_state = MediaPlayerState.OFF + _attr_state = MediaPlayerState.ON # Default to ON to ensure compatibility with models that don't send a powerstatus message _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE 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..ae1f366a588 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 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..bf33e284e67 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 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..aa4dedc16dd 100644 --- a/homeassistant/components/lg_thinq/number.py +++ b/homeassistant/components/lg_thinq/number.py @@ -1,7 +1,5 @@ """Support for number entities.""" -from __future__ import annotations - import logging from thinqconnect import DeviceType 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..d9e60cd6a50 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 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/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..3bf85b0b066 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 diff --git a/homeassistant/components/libre_hardware_monitor/coordinator.py b/homeassistant/components/libre_hardware_monitor/coordinator.py index 7c24fb753c1..2cdc2ca6cd3 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 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/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..d732675a4a1 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 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 81b2c570eab..460e9b7ca86 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 @@ -97,7 +95,8 @@ SENSOR_TYPES: dict[str, LidarrSensorEntityDescription[Any]] = { state_class=SensorStateClass.TOTAL, entity_registry_enabled_default=False, attributes_fn=lambda data: { - album.title: album.artist.artistName for album in data.records + album.title: album.artist.artistName # type: ignore[misc] + for album in data.records }, ), "albums": LidarrSensorEntityDescription[int]( diff --git a/homeassistant/components/liebherr/__init__.py b/homeassistant/components/liebherr/__init__.py index 79be6ec8175..753f2560557 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 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..c66829a167c 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 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/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..007fc3656b0 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 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..3a024ec84ad 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 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/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..cdeaaccace5 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 diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index de1f9841a50..becedf024e7 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) 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..59da55747b3 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 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/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 dd0ae383c92..1c438e5f7c1 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -1,9 +1,8 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted lights.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", + "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", "field_brightness_pct_description": "Number indicating the percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness, and 100 is the maximum brightness.", @@ -37,22 +36,21 @@ "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", - "trigger_behavior_description": "The behavior of the targeted lights to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", + "trigger_threshold_name": "Threshold type" }, "conditions": { "is_brightness": { "description": "Tests the brightness of one or more lights.", "fields": { "behavior": { - "description": "[%key:component::light::common::condition_behavior_description%]", "name": "[%key:component::light::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::light::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::light::common::condition_threshold_description%]", "name": "[%key:component::light::common::condition_threshold_name%]" } }, @@ -62,8 +60,10 @@ "description": "Tests if one or more lights are off.", "fields": { "behavior": { - "description": "[%key:component::light::common::condition_behavior_description%]", "name": "[%key:component::light::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::light::common::condition_for_name%]" } }, "name": "Light is off" @@ -72,8 +72,10 @@ "description": "Tests if one or more lights are on.", "fields": { "behavior": { - "description": "[%key:component::light::common::condition_behavior_description%]", "name": "[%key:component::light::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::light::common::condition_for_name%]" } }, "name": "Light is on" @@ -318,12 +320,6 @@ "yellowgreen": "Yellow green" } }, - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, "flash": { "options": { "long": "Long", @@ -335,13 +331,6 @@ "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } } }, "services": { @@ -513,7 +502,6 @@ "description": "Triggers after the brightness of one or more lights changes.", "fields": { "threshold": { - "description": "[%key:component::light::common::trigger_threshold_changed_description%]", "name": "[%key:component::light::common::trigger_threshold_name%]" } }, @@ -523,11 +511,12 @@ "description": "Triggers after the brightness of one or more lights crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::light::common::trigger_behavior_description%]", "name": "[%key:component::light::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::light::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::light::common::trigger_threshold_crossed_description%]", "name": "[%key:component::light::common::trigger_threshold_name%]" } }, @@ -537,8 +526,10 @@ "description": "Triggers after one or more lights turn off.", "fields": { "behavior": { - "description": "[%key:component::light::common::trigger_behavior_description%]", "name": "[%key:component::light::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::light::common::trigger_for_name%]" } }, "name": "Light turned off" @@ -547,8 +538,10 @@ "description": "Triggers after one or more lights turn on.", "fields": { "behavior": { - "description": "[%key:component::light::common::trigger_behavior_description%]", "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..4231f672a23 100644 --- a/homeassistant/components/light/triggers.yaml +++ b/homeassistant/components/light/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any 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/climate.py b/homeassistant/components/lightwave/climate.py index 136486f2492..32864f93a00 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 ( 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..3c94abd09e5 100644 --- a/homeassistant/components/linkplay/__init__.py +++ b/homeassistant/components/linkplay/__init__.py @@ -1,4 +1,5 @@ """Support for LinkPlay devices.""" +# pylint: disable=hass-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/media_player.py b/homeassistant/components/linkplay/media_player.py index 702aa0c7629..5eb9fac1f7b 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=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from datetime import timedelta import logging 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..0c55ddc50bf 100644 --- a/homeassistant/components/linkplay/utils.py +++ b/homeassistant/components/linkplay/utils.py @@ -1,4 +1,5 @@ """Utilities for the LinkPlay component.""" +# pylint: disable=hass-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/__init__.py b/homeassistant/components/linksys_smart/__init__.py index 489596c7ec6..a4bfa1c511b 100644 --- a/homeassistant/components/linksys_smart/__init__.py +++ b/homeassistant/components/linksys_smart/__init__.py @@ -1 +1 @@ -"""The linksys_smart component.""" +"""The Linksys Smart Wi-Fi integration.""" 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..b974aac7b36 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 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..c4063e65d0b 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, @@ -32,8 +30,11 @@ class RobotBinarySensorEntityDescription( 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 +59,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..ff204a827fe 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 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..a911a236062 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 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..b10f63e5a98 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 diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index 51bfecfbf25..3c37d582a62 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 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..dd5d2aeb20e 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 diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py index fa630625dcd..32078bf983e 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 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..4d768916d8a 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 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_file/camera.py b/homeassistant/components/local_file/camera.py index 4544f69dbee..3e225125dda 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 diff --git a/homeassistant/components/local_file/config_flow.py b/homeassistant/components/local_file/config_flow.py index 206e4c2a7c8..7509760e8b2 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 diff --git a/homeassistant/components/local_file/strings.json b/homeassistant/components/local_file/strings.json index 14866fa6300..d35b4e653c1 100644 --- a/homeassistant/components/local_file/strings.json +++ b/homeassistant/components/local_file/strings.json @@ -4,7 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" }, "error": { - "not_readable_path": "The provided path to the file can not be read" + "not_readable_path": "The provided path to the file cannot be read" }, "step": { "user": { 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/locative/__init__.py b/homeassistant/components/locative/__init__.py index 4154f343f42..32cc9895759 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=hass-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..16e207426f2 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=hass-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 fea8afdfb04..87d2928077e 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted locks.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted locks to trigger on.", - "trigger_behavior_name": "Behavior" + "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_jammed": { "description": "Tests if one or more locks are jammed.", "fields": { "behavior": { - "description": "[%key:component::lock::common::condition_behavior_description%]", "name": "[%key:component::lock::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::condition_for_name%]" } }, "name": "Lock is jammed" @@ -20,8 +22,10 @@ "description": "Tests if one or more locks are locked.", "fields": { "behavior": { - "description": "[%key:component::lock::common::condition_behavior_description%]", "name": "[%key:component::lock::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::condition_for_name%]" } }, "name": "Lock is locked" @@ -30,8 +34,10 @@ "description": "Tests if one or more locks are open.", "fields": { "behavior": { - "description": "[%key:component::lock::common::condition_behavior_description%]", "name": "[%key:component::lock::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::condition_for_name%]" } }, "name": "Lock is open" @@ -40,8 +46,10 @@ "description": "Tests if one or more locks are unlocked.", "fields": { "behavior": { - "description": "[%key:component::lock::common::condition_behavior_description%]", "name": "[%key:component::lock::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::condition_for_name%]" } }, "name": "Lock is unlocked" @@ -98,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.", @@ -151,8 +144,10 @@ "description": "Triggers after one or more locks jam.", "fields": { "behavior": { - "description": "[%key:component::lock::common::trigger_behavior_description%]", "name": "[%key:component::lock::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::trigger_for_name%]" } }, "name": "Lock jammed" @@ -161,8 +156,10 @@ "description": "Triggers after one or more locks lock.", "fields": { "behavior": { - "description": "[%key:component::lock::common::trigger_behavior_description%]", "name": "[%key:component::lock::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::trigger_for_name%]" } }, "name": "Lock locked" @@ -171,8 +168,10 @@ "description": "Triggers after one or more locks open.", "fields": { "behavior": { - "description": "[%key:component::lock::common::trigger_behavior_description%]", "name": "[%key:component::lock::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::trigger_for_name%]" } }, "name": "Lock opened" @@ -181,8 +180,10 @@ "description": "Triggers after one or more locks unlock.", "fields": { "behavior": { - "description": "[%key:component::lock::common::trigger_behavior_description%]", "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..d60b78c2c87 100644 --- a/homeassistant/components/lock/triggers.yaml +++ b/homeassistant/components/lock/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any 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..f5260851b16 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 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..a16cd2a86b5 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 diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index bef34f0858b..5ca80ce3d72 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 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/strings.json b/homeassistant/components/logbook/strings.json index d56cc2cfd69..e5d2ec3a822 100644 --- a/homeassistant/components/logbook/strings.json +++ b/homeassistant/components/logbook/strings.json @@ -20,7 +20,7 @@ "name": "[%key:common::config_flow::data::name%]" } }, - "name": "Log" + "name": "Log activity" } }, "title": "Activity" 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..711e8ba5afe 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 @@ -86,14 +85,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..9532cc3ada0 100644 --- a/homeassistant/components/lojack/device_tracker.py +++ b/homeassistant/components/lojack/device_tracker.py @@ -1,7 +1,5 @@ """Device tracker platform for LoJack integration.""" -from __future__ import annotations - from homeassistant.components.device_tracker import SourceType, TrackerEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo 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..d2aa95f2296 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 diff --git a/homeassistant/components/london_underground/const.py b/homeassistant/components/london_underground/const.py index 9c96ff1ece0..8d6425bc7a8 100644 --- a/homeassistant/components/london_underground/const.py +++ b/homeassistant/components/london_underground/const.py @@ -29,6 +29,8 @@ TUBE_LINES = [ "Suffragette", "Weaver", "Windrush", + "Tram", + "IFS Cloud Cable Car", ] # Default lines to monitor if none selected 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/manifest.json b/homeassistant/components/london_underground/manifest.json index 15cf41ef98c..d05376b863a 100644 --- a/homeassistant/components/london_underground/manifest.json +++ b/homeassistant/components/london_underground/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["london_tube_status"], - "requirements": ["london-tube-status==0.5"], + "requirements": ["london-tube-status==0.7"], "single_config_entry": true } diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index c9df10b470c..5c32bf716f8 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 diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 1814f95d5a1..f82835a08d0 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 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..f9ccb1e5fd9 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 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..74d8578b24a 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 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..dae507ca768 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -338,10 +338,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) diff --git a/homeassistant/components/lovelace/cast.py b/homeassistant/components/lovelace/cast.py index a0e6185b06f..6834bfd9d76 100644 --- a/homeassistant/components/lovelace/cast.py +++ b/homeassistant/components/lovelace/cast.py @@ -1,7 +1,5 @@ """Home Assistant Cast platform.""" -from __future__ import annotations - from typing import Any from pychromecast import Chromecast 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/websocket.py b/homeassistant/components/lovelace/websocket.py index f8eb7772c78..4214800b6dc 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 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 2e280168a86..3c1ae4b2f82 100644 --- a/homeassistant/components/lunatone/__init__.py +++ b/homeassistant/components/lunatone/__init__.py @@ -1,5 +1,6 @@ """The Lunatone integration.""" +import logging from typing import Final from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info @@ -7,9 +8,10 @@ from lunatone_rest_api_client import Auth, DALIBroadcast, Devices, Info from homeassistant.const import CONF_URL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from .config_flow import LunatoneConfigFlow from .const import DOMAIN, MANUFACTURER from .coordinator import ( LunatoneConfigEntry, @@ -18,27 +20,76 @@ from .coordinator import ( LunatoneInfoDataUpdateCoordinator, ) +_LOGGER = logging.getLogger(__name__) PLATFORMS: Final[list[Platform]] = [Platform.LIGHT] +async def _update_unique_id( + hass: HomeAssistant, entry: LunatoneConfigEntry, new_unique_id: str +) -> None: + _LOGGER.debug("Update unique ID") + + # Update all associated entities + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + + for entity in entities: + parts = list(entity.unique_id.partition("-")) + parts[0] = new_unique_id + + entity_registry.async_update_entity( + entity.entity_id, new_unique_id="".join(parts) + ) + + # Update all associated devices + device_registry = dr.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + + for device in devices: + identifier = device.identifiers.pop() + parts = list(identifier[1].partition("-")) + parts[0] = new_unique_id + + device_registry.async_update_device( + device.id, new_identifiers={(identifier[0], "".join(parts))} + ) + + # Update the config entry itself + hass.config_entries.async_update_entry( + entry, + unique_id=new_unique_id, + minor_version=LunatoneConfigFlow.MINOR_VERSION, + version=LunatoneConfigFlow.VERSION, + ) + + _LOGGER.debug("Update of unique ID successful") + + async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) -> bool: """Set up Lunatone from a config entry.""" auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL]) info_api = Info(auth_api) - devices_api = Devices(auth_api) + devices_api = Devices(info_api) coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api) await coordinator_info.async_config_entry_first_refresh() - if info_api.serial_number is None: + if info_api.data is None or info_api.serial_number is None: raise ConfigEntryError( translation_domain=DOMAIN, translation_key="missing_device_info" ) + if info_api.uid is not None: + new_unique_id = info_api.uid.replace("-", "") + if new_unique_id != entry.unique_id: + await _update_unique_id(hass, entry, new_unique_id) + + assert entry.unique_id + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, - identifiers={(DOMAIN, str(info_api.serial_number))}, + identifiers={(DOMAIN, entry.unique_id)}, name=info_api.name, manufacturer=MANUFACTURER, sw_version=info_api.version, diff --git a/homeassistant/components/lunatone/config_flow.py b/homeassistant/components/lunatone/config_flow.py index b5004ffdce4..fa9951d2ae7 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( @@ -52,22 +58,70 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN): if info_api.serial_number is None: errors["base"] = "missing_device_info" else: - await self.async_set_unique_id(str(info_api.serial_number)) + unique_id = str(info_api.serial_number) + if info_api.uid is not None: + unique_id = info_api.uid.replace("-", "") + await self.async_set_unique_id(unique_id) if self.source == SOURCE_RECONFIGURE: self._abort_if_unique_id_mismatch() return self.async_update_reload_and_abort( self._get_reconfigure_entry(), data_updates=data, title=url ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=url, data={CONF_URL: url}) + return self.async_create_entry(title=url, data=data) return self.async_show_form( - step_id="user", - data_schema=DATA_SCHEMA, - 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 + + 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..1c96d86c02d 100644 --- a/homeassistant/components/lunatone/coordinator.py +++ b/homeassistant/components/lunatone/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for handling data fetching and updates.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta import logging diff --git a/homeassistant/components/lunatone/light.py b/homeassistant/components/lunatone/light.py index a733fd6588b..f763c6668e1 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( @@ -41,17 +40,20 @@ async def async_setup_entry( coordinator_devices = config_entry.runtime_data.coordinator_devices dali_line_broadcasts = config_entry.runtime_data.dali_line_broadcasts + assert config_entry.unique_id is not None + entities: list[LightEntity] = [ LunatoneLineBroadcastLight( - coordinator_info, coordinator_devices, dali_line_broadcast + coordinator_info, + coordinator_devices, + dali_line_broadcast, + config_entry.unique_id, ) for dali_line_broadcast in dali_line_broadcasts ] entities.extend( [ - LunatoneLight( - coordinator_devices, device_id, coordinator_info.data.device.serial - ) + LunatoneLight(coordinator_devices, device_id, config_entry.unique_id) for device_id in coordinator_devices.data ] ) @@ -71,19 +73,21 @@ 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, coordinator: LunatoneDevicesDataUpdateCoordinator, device_id: int, - interface_serial_number: int, + config_entry_unique_id: str, ) -> None: """Initialize a Lunatone light.""" super().__init__(coordinator) self._device_id = device_id - self._interface_serial_number = interface_serial_number - self._device = self.coordinator.data[self._device_id] - self._attr_unique_id = f"{interface_serial_number}-device{device_id}" + self._config_entry_unique_id = config_entry_unique_id + self._device = self.coordinator.data[device_id] + self._attr_unique_id = f"{config_entry_unique_id}-device{device_id}" @property def device_info(self) -> DeviceInfo: @@ -94,7 +98,7 @@ class LunatoneLight( name=self._device.name, via_device=( DOMAIN, - f"{self._interface_serial_number}-line{self._device.data.line}", + f"{self._config_entry_unique_id}-line{self._device.data.line}", ), ) @@ -120,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 @@ -129,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.""" @@ -138,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: @@ -158,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() @@ -179,6 +223,7 @@ class LunatoneLineBroadcastLight( coordinator_info: LunatoneInfoDataUpdateCoordinator, coordinator_devices: LunatoneDevicesDataUpdateCoordinator, broadcast: DALIBroadcast, + config_entry_unique_id: str, ) -> None: """Initialize a Lunatone line broadcast light.""" super().__init__(coordinator_info) @@ -187,7 +232,7 @@ class LunatoneLineBroadcastLight( line = broadcast.line - self._attr_unique_id = f"{coordinator_info.data.device.serial}-line{line}" + self._attr_unique_id = f"{config_entry_unique_id}-line{line}" line_device = self.coordinator.data.lines[str(line)].device extra_info: dict = {} @@ -202,7 +247,7 @@ class LunatoneLineBroadcastLight( self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.unique_id)}, name=f"DALI Line {line}", - via_device=(DOMAIN, str(coordinator_info.data.device.serial)), + via_device=(DOMAIN, config_entry_unique_id), **extra_info, ) @@ -217,13 +262,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/strings.json b/homeassistant/components/lunatone/strings.json index 1ba52be8e54..76006f73eef 100644 --- a/homeassistant/components/lunatone/strings.json +++ b/homeassistant/components/lunatone/strings.json @@ -2,16 +2,19 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + "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" }, "step": { - "confirm": { - "description": "[%key:common::config_flow::description::confirm_setup%]" + "discovery_confirm": { + "description": "Do you want to setup the Lunatone device with {url}?" }, "reconfigure": { "data": { @@ -20,16 +23,16 @@ "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." } } } 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 0a15d5a20f8..ddecffb1a8f 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -4,11 +4,20 @@ from dataclasses import dataclass import logging from typing import Any, cast -from pylutron import Button, Keypad, Led, Lutron, OccupancyGroup, Output +from pylutron import ( + Button, + Keypad, + Led, + Lutron, + LutronException, + OccupancyGroup, + Output, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN @@ -20,6 +29,7 @@ PLATFORMS = [ Platform.FAN, Platform.LIGHT, Platform.SCENE, + Platform.SELECT, Platform.SWITCH, ] @@ -57,8 +67,12 @@ async def async_setup_entry( pwd = config_entry.data[CONF_PASSWORD] lutron_client = Lutron(host, uid, pwd) - await hass.async_add_executor_job(lutron_client.load_xml_db) - lutron_client.connect() + try: + await hass.async_add_executor_job(lutron_client.load_xml_db) + lutron_client.connect() + except LutronException as ex: + raise ConfigEntryNotReady(f"Failed to connect to Lutron repeater: {ex}") from ex + _LOGGER.debug("Connected to main repeater at %s", host) entity_registry = er.async_get(hass) @@ -79,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 @@ -189,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..6c1c946bad8 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 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/manifest.json b/homeassistant/components/lutron/manifest.json index e40203a6cca..b08676082cb 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pylutron"], - "requirements": ["pylutron==0.4.0"], + "requirements": ["pylutron==0.4.1"], "single_config_entry": true } 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..99d94951345 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 diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index f8de5c60df0..e9e087e5ff6 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -1,5 +1,6 @@ -"""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 @@ -8,7 +9,8 @@ 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): @@ -88,3 +101,41 @@ 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=hass-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.""" + status = await self._smartbridge.get_battery_status(self.device_id) + 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/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index b3bfaaa7c62..5ab026e8563 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 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/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/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..23ee08457ff 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 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..cb274efa54a 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 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/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/sensor.py b/homeassistant/components/lyric/sensor.py index f0a8d572353..106484f30da 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 diff --git a/homeassistant/components/madvr/__init__.py b/homeassistant/components/madvr/__init__.py index cf681bd0b65..a62c427c861 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 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/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..ec753ed384e 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 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..e69dfa4338e 100644 --- a/homeassistant/components/mailgun/__init__.py +++ b/homeassistant/components/mailgun/__init__.py @@ -1,4 +1,5 @@ """Support for Mailgun.""" +# pylint: disable=hass-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..97b02dab797 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=hass-use-runtime-data data = hass.data[DOMAIN] mailgun_service = MailgunNotificationService( data.get(CONF_DOMAIN), diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index 648368db6d0..1db3056a2b0 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 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/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..c5d7fe2286b 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, 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 592b6a2300e..805c01ad762 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" @@ -23,3 +24,16 @@ ATTR_MEDIA_DESCRIPTION = "media_description" ATTR_LANGUAGE = "language" ATTR_DURATION = "duration" ATTR_HIDE_NOTIFICATIONS = "hide_notifications" + +ATTR_DISPLAY_NAME = "display_name" +ATTR_NOTE = "note" +ATTR_AVATAR = "avatar" +ATTR_AVATAR_MIME_TYPE = "avatar_mime_type" +ATTR_HEADER = "header" +ATTR_HEADER_MIME_TYPE = "header_mime_type" +ATTR_LOCKED = "locked" +ATTR_BOT = "bot" +ATTR_DISCOVERABLE = "discoverable" +ATTR_FIELDS = "fields" +ATTR_ATTRIBUTION_DOMAINS = "attribution_domains" +ATTR_VALUE = "value" diff --git a/homeassistant/components/mastodon/coordinator.py b/homeassistant/components/mastodon/coordinator.py index 5246bbd413a..a4d7f22256e 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 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/icons.json b/homeassistant/components/mastodon/icons.json index e9185ee13b1..dd2974378f0 100644 --- a/homeassistant/components/mastodon/icons.json +++ b/homeassistant/components/mastodon/icons.json @@ -43,6 +43,9 @@ }, "unmute_account": { "service": "mdi:account-voice" + }, + "update_profile": { + "service": "mdi:account-edit" } } } 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 2208588570c..018b91d80d3 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -4,6 +4,7 @@ from datetime import timedelta from enum import StrEnum from functools import partial from math import isfinite +from pathlib import Path from typing import Any from mastodon import Mastodon @@ -11,11 +12,14 @@ from mastodon.Mastodon import ( Account, MastodonAPIError, MastodonNotFoundError, + MastodonUnauthorizedError, MediaAttachment, ) import voluptuous as vol -from homeassistant.const import ATTR_CONFIG_ENTRY_ID +from homeassistant.components import camera, image +from homeassistant.components.media_source import async_resolve_media +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_NAME from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -25,20 +29,35 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, service +from homeassistant.helpers.selector import MediaSelector from .const import ( ATTR_ACCOUNT_NAME, + ATTR_ATTRIBUTION_DOMAINS, + ATTR_AVATAR, + ATTR_AVATAR_MIME_TYPE, + ATTR_BOT, ATTR_CONTENT_WARNING, + ATTR_DISCOVERABLE, + ATTR_DISPLAY_NAME, ATTR_DURATION, + ATTR_FIELDS, + ATTR_HEADER, + ATTR_HEADER_MIME_TYPE, ATTR_HIDE_NOTIFICATIONS, ATTR_IDEMPOTENCY_KEY, ATTR_LANGUAGE, + ATTR_LOCKED, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, ATTR_MEDIA_WARNING, + ATTR_NOTE, + ATTR_QUOTE_APPROVAL_POLICY, ATTR_STATUS, + ATTR_VALUE, ATTR_VISIBILITY, DOMAIN, + LOGGER, ) from .coordinator import MastodonConfigEntry from .utils import get_media_type @@ -55,6 +74,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( { @@ -89,6 +116,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, @@ -98,6 +128,24 @@ SERVICE_POST_SCHEMA = vol.Schema( } ) +SERVICE_UPDATE_PROFILE = "update_profile" +SERVICE_UPDATE_PROFILE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + vol.Optional(ATTR_DISPLAY_NAME): str, + vol.Optional(ATTR_NOTE): str, + vol.Optional(ATTR_AVATAR): MediaSelector({"accept": ["image/*"]}), + vol.Optional(ATTR_HEADER): MediaSelector({"accept": ["image/*"]}), + vol.Optional(ATTR_LOCKED): bool, + vol.Optional(ATTR_BOT): bool, + vol.Optional(ATTR_DISCOVERABLE): bool, + vol.Optional(ATTR_FIELDS): vol.All( + cv.ensure_list, vol.Length(max=4), [dict[str, str]] + ), + vol.Optional(ATTR_ATTRIBUTION_DOMAINS): vol.All(cv.ensure_list, [str]), + } +) + @callback def async_setup_services(hass: HomeAssistant) -> None: @@ -124,6 +172,13 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, SERVICE_POST, _async_post, schema=SERVICE_POST_SCHEMA ) + hass.services.async_register( + DOMAIN, + SERVICE_UPDATE_PROFILE, + _async_update_profile, + schema=SERVICE_UPDATE_PROFILE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) async def _async_account_lookup( @@ -244,6 +299,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) @@ -264,6 +324,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, @@ -319,3 +380,71 @@ def _post(hass: HomeAssistant, client: Mastodon, **kwargs: Any) -> None: translation_domain=DOMAIN, translation_key="unable_to_send_message", ) from err + + +async def _async_update_profile(call: ServiceCall) -> ServiceResponse: + """Update profile information.""" + params = dict(call.data.copy()) + + entry: MastodonConfigEntry = service.async_get_config_entry( + call.hass, DOMAIN, params.pop(ATTR_CONFIG_ENTRY_ID) + ) + client = entry.runtime_data.client + + if avatar := params.pop(ATTR_AVATAR, None): + params[ATTR_AVATAR], params[ATTR_AVATAR_MIME_TYPE] = await _resolve_media( + call.hass, avatar + ) + if header := params.pop(ATTR_HEADER, None): + params[ATTR_HEADER], params[ATTR_HEADER_MIME_TYPE] = await _resolve_media( + call.hass, header + ) + if fields := params.get(ATTR_FIELDS): + params[ATTR_FIELDS] = [ + (field[ATTR_NAME].strip(), field[ATTR_VALUE].strip()) + for field in fields + if field[ATTR_NAME].strip() + ] + try: + return await call.hass.async_add_executor_job( + lambda: client.account_update_credentials(**params) + ) + except MastodonUnauthorizedError as error: + entry.async_start_reauth(call.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="auth_failed", + ) from error + except MastodonAPIError as err: + LOGGER.debug("Full exception:", exc_info=err) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_update_profile", + ) from err + + +async def _resolve_media( + hass: HomeAssistant, media_source: dict[str, str] +) -> tuple[bytes | Path, str | None]: + """Resolve media from a media source.""" + media_content_id: str = media_source["media_content_id"] + if media_content_id.startswith("media-source://camera/"): + entity_id = media_content_id.removeprefix("media-source://camera/") + snapshot = await camera.async_get_image(hass, entity_id) + return snapshot.content, snapshot.content_type + + if media_content_id.startswith("media-source://image/"): + entity_id = media_content_id.removeprefix("media-source://image/") + img = await image.async_get_image(hass, entity_id) + return img.content, img.content_type + + media = await async_resolve_media(hass, media_source["media_content_id"], None) + + if media.path is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="media_source_not_supported", + translation_placeholders={"media_content_id": media_content_id}, + ) + + return media.path, media.mime_type diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml index bdeefc8b570..0fce29eff41 100644 --- a/homeassistant/components/mastodon/services.yaml +++ b/homeassistant/components/mastodon/services.yaml @@ -1,6 +1,6 @@ get_account: fields: - config_entry_id: + config_entry_id: &config_entry_id required: true selector: config_entry: @@ -11,11 +11,7 @@ get_account: text: mute_account: fields: - config_entry_id: - required: true - selector: - config_entry: - integration: mastodon + config_entry_id: *config_entry_id account_name: required: true selector: @@ -32,22 +28,14 @@ mute_account: boolean: unmute_account: fields: - config_entry_id: - required: true - selector: - config_entry: - integration: mastodon + config_entry_id: *config_entry_id account_name: required: true selector: text: post: fields: - config_entry_id: - required: true - selector: - config_entry: - integration: mastodon + config_entry_id: *config_entry_id status: required: true selector: @@ -62,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: @@ -282,3 +278,55 @@ post: required: true selector: boolean: +update_profile: + fields: + config_entry_id: *config_entry_id + display_name: + selector: + text: + note: + selector: + text: + multiline: true + avatar: + required: false + selector: + media: + accept: + - "image/*" + header: + required: false + selector: + media: + accept: + - "image/*" + locked: + selector: + boolean: + bot: + selector: + boolean: + discoverable: + selector: + boolean: + fields: + selector: + object: + label_field: "value" + description_field: "name" + multiple: true + translation_key: fields + fields: + name: + required: true + selector: + text: + value: + required: true + selector: + text: + attribution_domains: + selector: + text: + multiple: true + type: url diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 5bfc629f1f3..720c0c71851 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -104,6 +104,9 @@ "idempotency_key_too_short": { "message": "Idempotency key must be at least 4 characters long." }, + "media_source_not_supported": { + "message": "Media source {media_content_id} is not supported." + }, "mute_duration_too_long": { "message": "Mute duration is too long." }, @@ -122,11 +125,26 @@ "unable_to_unmute_account": { "message": "Unable to unmute account \"{account_name}\"" }, + "unable_to_update_profile": { + "message": "Unable to update profile." + }, "unable_to_upload_image": { "message": "Unable to upload image {media_path}." } }, "selector": { + "fields": { + "fields": { + "name": { + "description": "The label for this field.", + "name": "Label" + }, + "value": { + "description": "The value for this field.", + "name": "Value" + } + } + }, "post_visibility": { "options": { "direct": "Direct - Mentioned accounts only", @@ -134,6 +152,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": { @@ -204,6 +229,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" @@ -228,6 +257,52 @@ } }, "name": "Unmute account" + }, + "update_profile": { + "description": "Updates your Mastodon profile information and pictures.", + "fields": { + "attribution_domains": { + "description": "Websites allowed to credit you. Protects from false attributions. Note that setting attribution domains will replace all existing attribution domains, not just the ones specified here.", + "name": "Attribution domains" + }, + "avatar": { + "description": "An image to set as your profile picture. WEBP, PNG, or JPG. At most 8 MB. Will be downscaled to 400x400px.", + "name": "Profile picture" + }, + "bot": { + "description": "Signal to others that the account mainly performs automated actions.", + "name": "Automated account" + }, + "config_entry_id": { + "description": "Select the Mastodon account to update the profile of.", + "name": "[%key:component::mastodon::services::post::fields::config_entry_id::name%]" + }, + "discoverable": { + "description": "Whether your profile should be discoverable. Public posts and the profile may be featured or recommended across Mastodon.", + "name": "Discoverable" + }, + "display_name": { + "description": "The display name to set on your profile.", + "name": "Display name" + }, + "fields": { + "description": "Additional profile fields as key-value pairs. Your homepage, pronouns, age, anything you want. Note that updating fields will replace all existing fields, not just the ones specified here.", + "name": "Extra fields" + }, + "header": { + "description": "An image to set as your profile header. WEBP, PNG, or JPG. At most 8 MB. Will be downscaled to 1500x500px.", + "name": "Header picture" + }, + "locked": { + "description": "Whether to lock your profile. A locked profile requires you to approve followers and hides your posts from non-followers.", + "name": "Lock profile" + }, + "note": { + "description": "The bio to set on your profile. You can @mention other people or #hashtags.", + "name": "Bio" + } + }, + "name": "Update profile" } } } 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..84c8b441f10 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] @@ -504,8 +508,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..5de53b47f8d 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -1,7 +1,5 @@ """The Matter integration.""" -from __future__ import annotations - import asyncio from functools import cache @@ -16,7 +14,7 @@ from matter_server.client.exceptions import ( from matter_server.common.errors import MatterError, NodeNotExists 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 +33,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, @@ -55,8 +54,7 @@ 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 @@ -74,7 +72,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) @@ -152,13 +150,10 @@ 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) + entry.runtime_data = MatterEntryData(matter, listen_task) await hass.config_entries.async_forward_entry_setups(entry, SUPPORTED_PLATFORMS) await matter.setup_nodes() @@ -166,7 +161,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # 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: @@ -177,7 +171,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _client_listen( hass: HomeAssistant, - entry: ConfigEntry, + entry: MatterConfigEntry, matter_client: MatterClient, init_ready: asyncio.Event, ) -> None: @@ -199,16 +193,15 @@ 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() + 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 +215,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 +239,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 +252,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 +281,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: 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..e054205d05c 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 diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 84ed60d580b..a941a55c1c1 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) 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 d1699beaa60..af99e41b856 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 @@ -132,6 +129,7 @@ SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = { (0x1209, 0x8027), (0x1209, 0x8028), (0x1209, 0x8029), + (0x138C, 0x0101), } SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = { @@ -172,6 +170,7 @@ SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = { (0x1209, 0x8028), (0x1209, 0x8029), (0x131A, 0x1000), + (0x138C, 0x0101), } SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum @@ -192,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) diff --git a/homeassistant/components/matter/config_flow.py b/homeassistant/components/matter/config_flow.py index 0c73ccd4089..c53d326ac48 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 diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index 2d81577772a..e8360a54c51 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. @@ -54,11 +51,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) diff --git a/homeassistant/components/matter/diagnostics.py b/homeassistant/components/matter/diagnostics.py index 23b6854c791..1c055011d5c 100644 --- a/homeassistant/components/matter/diagnostics.py +++ b/homeassistant/components/matter/diagnostics.py @@ -1,7 +1,5 @@ """Provide diagnostics for Matter.""" -from __future__ import annotations - from copy import deepcopy from typing import Any @@ -9,11 +7,10 @@ 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.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} @@ -41,7 +38,7 @@ 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) @@ -54,7 +51,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) diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 278eb8b7e83..c5bdb80e41d 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 diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 80a50491e46..2fd125b40f5 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,7 +123,9 @@ 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 + 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 @@ -212,6 +212,24 @@ 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, + ) + ) # subscribe to FeatureMap attribute (as that can dynamically change) self._unsubscribes.append( self.matter_client.subscribe_events( @@ -237,10 +255,22 @@ class MatterEntity(Entity): name = f"{name} ({self._name_postfix})" return name + @callback + def _get_bridged_reachable(self) -> bool: + """Return reachability state for bridged endpoints, True if not applicable.""" + 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() diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index d840daad8ba..1dc4815d384 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) diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py index 823451113e0..2a15fc29b47 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) @@ -254,8 +251,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 +304,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 ) @@ -323,7 +326,11 @@ DISCOVERY_SCHEMAS = [ required_attributes=( clusters.FanControl.Attributes.FanMode, clusters.FanControl.Attributes.PercentCurrent, + clusters.FanControl.Attributes.PercentSetting, ), + # PercentSetting SHALL be null when FanMode is Auto (spec 4.4.6.3), + # so allow null values to not block discovery in that state. + allow_none_value=True, optional_attributes=( clusters.FanControl.Attributes.SpeedSetting, clusters.FanControl.Attributes.RockSetting, diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index fc06bfd4822..82c5a22f4f9 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 @@ -31,14 +30,17 @@ class MatterEntryData: listen_task: asyncio.Task +type MatterConfigEntry = ConfigEntry[MatterEntryData] + + @callback def get_matter(hass: HomeAssistant) -> MatterAdapter: """Return MatterAdapter instance.""" # 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/light.py b/homeassistant/components/matter/light.py index 599f34bc9f4..cbf8aa1adfc 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, @@ -86,11 +83,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) 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..7fb7c3eaec7 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -8,6 +8,6 @@ "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.6.0"], "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 91b5fd05c4b..d606fc9c7f7 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) @@ -399,6 +396,72 @@ DISCOVERY_SCHEMAS = [ ), entity_class=MatterNumber, required_attributes=(clusters.OccupancySensing.Attributes.HoldTime,), + # HoldTime is shared by PIR-specific numbers as a required attribute. + # Keep discovery open so this generic schema does not block them. + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="OccupancySensingPIRUnoccupiedToOccupiedDelay", + entity_category=EntityCategory.CONFIG, + translation_key="detection_delay", + native_max_value=65534, + native_min_value=0, + native_unit_of_measurement=UnitOfTime.SECONDS, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=( + clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedDelay, + # This attribute is mandatory when the PIRUnoccupiedToOccupiedDelay is present + clusters.OccupancySensing.Attributes.HoldTime, + ), + featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared, + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="OccupancySensingPIRUnoccupiedToOccupiedThreshold", + entity_category=EntityCategory.CONFIG, + translation_key="detection_threshold", + native_max_value=254, + native_min_value=1, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=( + clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedThreshold, + clusters.OccupancySensing.Attributes.HoldTime, + ), + 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, diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index a0c87cc4974..e88ed8a9781 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,13 +9,12 @@ 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 = { @@ -66,11 +63,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) @@ -560,11 +557,15 @@ 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={ @@ -584,12 +585,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 +615,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 +642,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..42ca3907569 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,7 +21,6 @@ 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, @@ -50,7 +47,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 = { @@ -225,11 +222,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) @@ -526,7 +523,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 +550,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, @@ -789,7 +784,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 +800,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 +816,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 +830,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, @@ -896,7 +887,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 +1042,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 +1061,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 +1079,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, 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/strings.json b/homeassistant/components/matter/strings.json index b790c0b7213..514ba606fa5 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -141,6 +141,9 @@ }, "stop": { "name": "[%key:common::action::stop%]" + }, + "temporary_mute_request": { + "name": "Temporary mute" } }, "climate": { @@ -223,6 +226,12 @@ "cook_time": { "name": "Cooking time" }, + "detection_delay": { + "name": "Detection delay" + }, + "detection_threshold": { + "name": "Detection threshold" + }, "hold_time": { "name": "Hold time" }, @@ -253,6 +262,9 @@ "pump_setpoint": { "name": "Setpoint" }, + "sensitivity_level": { + "name": "Sensitivity" + }, "speaker_setpoint": { "name": "Volume" }, diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 7c125763703..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) @@ -206,7 +203,6 @@ DISCOVERY_SCHEMAS = [ device_types.Cooktop, device_types.Dishwasher, device_types.ExtractorHood, - device_types.HeatingCoolingUnit, device_types.LaundryDryer, device_types.LaundryWasher, device_types.Oven, @@ -241,7 +237,6 @@ DISCOVERY_SCHEMAS = [ device_types.Dishwasher, device_types.ExtractorHood, device_types.Fan, - device_types.HeatingCoolingUnit, device_types.LaundryDryer, device_types.LaundryWasher, device_types.Oven, @@ -319,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..e79f7b6b226 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) 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..a434e030319 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) 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 fc67d663a6e..dbbb79a460e 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,7 +28,7 @@ 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 @@ -48,11 +45,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) @@ -168,10 +165,15 @@ class MatterWaterHeater(MatterEntity, WaterHeaterEntity): self._attr_target_temperature = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.OccupiedHeatingSetpoint ) + system_mode = self.get_matter_attribute_value( + clusters.Thermostat.Attributes.SystemMode + ) boost_state = self.get_matter_attribute_value( clusters.WaterHeaterManagement.Attributes.BoostState ) - if boost_state == clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: + if system_mode == clusters.Thermostat.Enums.SystemModeEnum.kOff: + self._attr_current_operation = STATE_OFF + elif boost_state == clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive: self._attr_current_operation = STATE_HIGH_DEMAND else: self._attr_current_operation = STATE_ECO @@ -218,6 +220,7 @@ DISCOVERY_SCHEMAS = [ clusters.Thermostat.Attributes.AbsMinHeatSetpointLimit, clusters.Thermostat.Attributes.AbsMaxHeatSetpointLimit, clusters.Thermostat.Attributes.LocalTemperature, + clusters.Thermostat.Attributes.SystemMode, clusters.WaterHeaterManagement.Attributes.FeatureMap, ), optional_attributes=( 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..95667e9ce1a 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 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..b534527e530 100644 --- a/homeassistant/components/mcp/__init__.py +++ b/homeassistant/components/mcp/__init__.py @@ -1,7 +1,5 @@ """The Model Context Protocol integration.""" -from __future__ import annotations - from dataclasses import dataclass from typing import cast 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..1e3de3112eb 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 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/http.py b/homeassistant/components/mcp_server/http.py index 19ace718564..980015bc3a4 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 @@ -102,17 +104,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 +169,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 +275,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..ca07e22e680 100644 --- a/homeassistant/components/mcp_server/manifest.json +++ b/homeassistant/components/mcp_server/manifest.json @@ -3,7 +3,7 @@ "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", 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 6f9e61fd0fd..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.2"] + "requirements": ["aiomealie==1.2.4"] } 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..3d9436147e1 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 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..a56b2088eac 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 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 ea9dd0adcfd..f0de8cf6bca 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1,7 +1,5 @@ """Component to interface with various media players.""" -from __future__ import annotations - import asyncio import collections from collections.abc import Callable @@ -59,7 +57,6 @@ 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 @@ -246,7 +243,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. diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py index ec9d70476a3..5238868781d 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 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/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..b767cc9904f 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": { @@ -123,8 +132,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 8ff5d13b225..2347f3a2ecd 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -1,17 +1,33 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted media players.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted media players to trigger on.", - "trigger_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "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": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "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" @@ -20,8 +36,10 @@ "description": "Tests if one or more media players are off.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" } }, "name": "Media player is off" @@ -30,8 +48,10 @@ "description": "Tests if one or more media players are on.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" } }, "name": "Media player is on" @@ -40,8 +60,10 @@ "description": "Tests if one or more media players are paused.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" } }, "name": "Media player is paused" @@ -50,11 +72,40 @@ "description": "Tests if one or more media players are playing.", "fields": { "behavior": { - "description": "[%key:component::media_player::common::condition_behavior_description%]", "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": { @@ -221,12 +272,6 @@ } }, "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, "enqueue": { "options": { "add": "Add to queue", @@ -241,13 +286,6 @@ "off": "[%key:common::state::off%]", "one": "Repeat one" } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } } }, "services": { @@ -267,7 +305,7 @@ }, "clear_playlist": { "description": "Removes all items from a media player's playlist.", - "name": "Clear playlist" + "name": "Clear media player playlist" }, "join": { "description": "Groups media players together for synchronous playback. Only works on supported multiroom audio systems.", @@ -277,44 +315,44 @@ "name": "Group members" } }, - "name": "Join" + "name": "Join media players" }, "media_next_track": { - "description": "Selects the next track.", - "name": "Next" + "description": "Selects the next track on a media player.", + "name": "Next track" }, "media_pause": { "description": "Pauses playback on a media player.", - "name": "[%key:common::action::pause%]" + "name": "Pause media" }, "media_play": { "description": "Starts playback on a media player.", - "name": "Play" + "name": "Play media" }, "media_play_pause": { "description": "Toggles play/pause on a media player.", - "name": "Play/Pause" + "name": "Play/Pause media" }, "media_previous_track": { - "description": "Selects the previous track.", - "name": "Previous" + "description": "Selects the previous track on a media player.", + "name": "Previous track" }, "media_seek": { - "description": "Allows you to go to a different part of the media that is currently playing.", + "description": "Allows you to go to a different part of the media that is currently playing on a media player.", "fields": { "seek_position": { "description": "Target position in the currently playing media. The format is platform dependent.", "name": "Position" } }, - "name": "Seek" + "name": "Seek media" }, "media_stop": { "description": "Stops playback on a media player.", - "name": "[%key:common::action::stop%]" + "name": "Stop media" }, "play_media": { - "description": "Starts playing specified media.", + "description": "Starts playing specified media on a media player.", "fields": { "announce": { "description": "If the media should be played as an announcement.", @@ -332,14 +370,14 @@ "name": "Play media" }, "repeat_set": { - "description": "Sets the repeat mode.", + "description": "Sets the repeat mode of a media player.", "fields": { "repeat": { "description": "Whether the media (one or all) should be played in a loop or not.", "name": "Repeat mode" } }, - "name": "Set repeat" + "name": "Set media player repeat" }, "search_media": { "description": "Searches the available media.", @@ -364,14 +402,14 @@ "name": "Search media" }, "select_sound_mode": { - "description": "Selects a specific sound mode.", + "description": "Selects a specific sound mode of a media player.", "fields": { "sound_mode": { "description": "Name of the sound mode to switch to.", "name": "Sound mode" } }, - "name": "Select sound mode" + "name": "Select media player sound mode" }, "select_source": { "description": "Sends a media player the command to change the input source.", @@ -381,37 +419,37 @@ "name": "Source" } }, - "name": "Select source" + "name": "Select media player source" }, "shuffle_set": { - "description": "Enables or disables the shuffle mode.", + "description": "Enables or disables the shuffle mode of a media player.", "fields": { "shuffle": { "description": "Whether the media should be played in randomized order or not.", "name": "Shuffle mode" } }, - "name": "Set shuffle" + "name": "Set media player shuffle" }, "toggle": { "description": "Toggles a media player on/off.", - "name": "[%key:common::action::toggle%]" + "name": "Toggle media player" }, "turn_off": { "description": "Turns off the power of a media player.", - "name": "[%key:common::action::turn_off%]" + "name": "Turn off media player" }, "turn_on": { "description": "Turns on the power of a media player.", - "name": "[%key:common::action::turn_on%]" + "name": "Turn on media player" }, "unjoin": { "description": "Removes a media player from a group. Only works on platforms which support player groups.", - "name": "Unjoin" + "name": "Unjoin media player" }, "volume_down": { "description": "Turns down the volume of a media player.", - "name": "Turn down volume" + "name": "Turn down media player volume" }, "volume_mute": { "description": "Mutes or unmutes a media player.", @@ -421,7 +459,7 @@ "name": "Muted" } }, - "name": "Mute/unmute volume" + "name": "Mute/unmute media player" }, "volume_set": { "description": "Sets the volume level of a media player.", @@ -431,24 +469,122 @@ "name": "Level" } }, - "name": "Set volume" + "name": "Set media player volume" }, "volume_up": { "description": "Turns up the volume of a media player.", - "name": "Turn up volume" + "name": "Turn up media player volume" } }, "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": { - "description": "[%key:component::media_player::common::trigger_behavior_description%]", "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..fa6def22a3a 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 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..f60ed6b663d 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 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..32e47381b35 100644 --- a/homeassistant/components/media_source/helper.py +++ b/homeassistant/components/media_source/helper.py @@ -1,14 +1,11 @@ """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 @@ -37,7 +34,6 @@ def _get_media_item( return item -@bind_hass async def async_browse_media( hass: HomeAssistant, media_content_id: str | None, @@ -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..01ad2d0b645 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 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..fc6ba3fad9e 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 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/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..95d4ee79fb7 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 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..048b58e0515 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure Met component.""" -from __future__ import annotations - from typing import Any import voluptuous as vol 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/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/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 023347a1a8d..91d1df4ec4e 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=hass-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..474e7d38e42 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 diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 7076edb4f99..6fb622a94e8 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, @@ -184,6 +185,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, 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..48a60c8187c 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 diff --git a/homeassistant/components/meteo_lt/weather.py b/homeassistant/components/meteo_lt/weather.py index ec48bbf2a12..46b70b449b4 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 diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py index 95124445363..b96da4ead1e 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 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..aa7e61e2908 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 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/__init__.py b/homeassistant/components/mfi/__init__.py index de354dfbc37..df797caeeab 100644 --- a/homeassistant/components/mfi/__init__.py +++ b/homeassistant/components/mfi/__init__.py @@ -1 +1 @@ -"""The mfi component.""" +"""The Ubiquiti mFI mPort integration.""" 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/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py index 5a8d9c3dae0..443e934a318 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 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 52d728ef9db..b0461c8d3e7 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -19,9 +19,13 @@ LIGHT = "light" 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). DISABLED_TEMP_ENTITIES = ( -32768 / 100, -32766 / 100, + -32768.0, + -32766.0, ) @@ -368,9 +372,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): @@ -473,6 +479,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 @@ -494,7 +501,7 @@ class DishWasherProgramId(MieleEnum, missing_to_none=True): intensive = 1, 26, 205 maintenance = 2, 27, 214 eco = 3, 22, 28, 200 - automatic = 6, 7, 31, 32, 202 + automatic = 6, 7, 31, 32, 201, 202 solar_save = 9, 34 gentle = 10, 35, 210 extra_quiet = 11, 36, 207 @@ -625,7 +632,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..8d93262d800 100644 --- a/homeassistant/components/miele/coordinator.py +++ b/homeassistant/components/miele/coordinator.py @@ -1,7 +1,5 @@ """Coordinator module for Miele integration.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/miele/diagnostics.py b/homeassistant/components/miele/diagnostics.py index 4d7d629139a..3b532342998 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 diff --git a/homeassistant/components/miele/fan.py b/homeassistant/components/miele/fan.py index ae500898b4e..ef8f2f59296 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 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/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 9802000e8c4..0ab551e3e18 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, @@ -93,7 +92,14 @@ def _convert_temperature( """Convert temperature object to readable value.""" if index >= len(value_list): return None - raw_value = cast(int, value_list[index].temperature) / 100.0 + raw = value_list[index].temperature + if raw is None: + return None + try: + raw_centi = int(raw) + except TypeError, ValueError: + return None + raw_value = raw_centi / 100.0 if raw_value in DISABLED_TEMP_ENTITIES: return None return raw_value @@ -639,6 +645,7 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition[MieleDevice], ...]] = ( MieleAppliance.OVEN, MieleAppliance.OVEN_MICROWAVE, MieleAppliance.STEAM_OVEN_COMBI, + MieleAppliance.STEAM_OVEN_MK2, ), description=MieleSensorDescription( key="state_core_temperature", @@ -840,9 +847,9 @@ async def async_setup_entry( and definition.description.value_fn(device) is None and definition.description.zone != 1 ): - # all appliances supporting temperature have at least zone 1, for other zones - # don't create entity if API signals that datapoint is disabled, unless the sensor - # already appeared in the past (= it provided a valid value) + # Optional temperature datapoints (extra fridge zones, oven food probe): only + # create the entity after the API first reports a valid reading, then keep it + # so state can return to unknown when the datapoint is inactive. return _is_entity_registered(unique_id) if ( definition.description.key == "state_plate_step" diff --git a/homeassistant/components/miele/switch.py b/homeassistant/components/miele/switch.py index 9940304bd8c..964fcb3cef5 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 diff --git a/homeassistant/components/miele/vacuum.py b/homeassistant/components/miele/vacuum.py index a47bcdb1c32..136e05c6bb5 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 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/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index a94d3b4b64e..fb5fe499b88 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 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..6509ee17d29 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() 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..041df839b4e 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 diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 4bcb5f6cb88..a1608b1fe83 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 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/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..30f66c88dad 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 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..0cf7ede4d57 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/__init__.py @@ -0,0 +1,95 @@ +"""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.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT, DOMAIN, PLATFORMS +from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def _make_device( + info: DeviceInfo, + serial: 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=info.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", + ) + + coordinators: dict[str, MitsubishiComfortCoordinator] = {} + for serial, info in devices.items(): + if not info.address or not info.password or not info.crypto_serial: + _LOGGER.warning("Device %s missing credentials, skipping", info.label) + continue + device = _make_device(info, serial, 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..9579be61958 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/config_flow.py @@ -0,0 +1,73 @@ +"""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.aiohttp_client import async_get_clientsession + +from .const import 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 + ) diff --git a/homeassistant/components/mitsubishi_comfort/const.py b/homeassistant/components/mitsubishi_comfort/const.py new file mode 100644 index 00000000000..c2d74d0959a --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/const.py @@ -0,0 +1,12 @@ +"""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] +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..b7825148082 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "mitsubishi_comfort", + "name": "Mitsubishi Comfort", + "codeowners": ["@nikolairahimi"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mitsubishi_comfort", + "integration_type": "hub", + "iot_class": "cloud_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..ec0bbccff4a --- /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: todo + 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..5a1c32c2260 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 diff --git a/homeassistant/components/mjpeg/config_flow.py b/homeassistant/components/mjpeg/config_flow.py index 5afd796f73f..f331b7e0137 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 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..dde74cf780e 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 ( diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 2711f945788..441022aaf6e 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=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from contextlib import suppress from functools import partial @@ -55,7 +56,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) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index a4ed3ea598b..46c730a314a 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -82,6 +82,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" 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..2cdd47c4f0d 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -1,6 +1,11 @@ """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, @@ -22,10 +27,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 +43,49 @@ 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, + }, + ), +) + + +@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 +100,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: @@ -132,12 +179,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 +203,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 +217,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..8678d194221 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 @@ -110,6 +108,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=hass-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..bf4ddff71e0 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 @@ -170,6 +168,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=hass-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..7578f3fad7e 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 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..9c19e93ee91 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=hass-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,88 @@ 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,7 +145,7 @@ 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 @@ -118,87 +202,119 @@ 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" ) - 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..7aa2c3facb1 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=hass-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..278e62b2c9b 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=hass-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,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_ID, ATTR_DOMAIN, - ATTR_GPS_ACCURACY, ATTR_SERVICE, ATTR_SERVICE_DATA, ATTR_SUPPORTED_FEATURES, @@ -58,11 +51,9 @@ 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, @@ -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: diff --git a/homeassistant/components/mobile_app/websocket_api.py b/homeassistant/components/mobile_app/websocket_api.py index e862e4c8bd5..b431555d8b1 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=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from functools import wraps from typing import Any diff --git a/homeassistant/components/mochad/light.py b/homeassistant/components/mochad/light.py index fe5a8ccd07d..eff8e406e0e 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 diff --git a/homeassistant/components/mochad/switch.py b/homeassistant/components/mochad/switch.py index beb12d9d409..085122cf1d3 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 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..4c5e7f553c1 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 diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 21d04d2ffc4..c72062f64cd 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 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..3ce52f75e2d 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 ( diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 5f376806d7c..db98f8d91b7 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 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..cb320c56a45 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 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..ec35206444c 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): @@ -62,30 +60,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 +91,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..a626f10ecbe 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 diff --git a/homeassistant/components/modern_forms/light.py b/homeassistant/components/modern_forms/light.py index 213e14b31a9..5fa07adf06b 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 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..76436a0dd62 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 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 c2f9705bcca..72ef64f7cf9 100644 --- a/homeassistant/components/moisture/strings.json +++ b/homeassistant/components/moisture/strings.json @@ -1,22 +1,21 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "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": { "is_detected": { "description": "Tests if one or more moisture sensors are detecting moisture.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::condition_behavior_description%]", "name": "[%key:component::moisture::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::moisture::common::condition_for_name%]" } }, "name": "Moisture is detected" @@ -25,8 +24,10 @@ "description": "Tests if one or more moisture sensors are not detecting moisture.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::condition_behavior_description%]", "name": "[%key:component::moisture::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::moisture::common::condition_for_name%]" } }, "name": "Moisture is not detected" @@ -35,39 +36,24 @@ "description": "Tests the moisture level of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::condition_behavior_description%]", "name": "[%key:component::moisture::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::moisture::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::moisture::common::condition_threshold_description%]", "name": "[%key:component::moisture::common::condition_threshold_name%]" } }, "name": "Moisture level" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Moisture", "triggers": { "changed": { "description": "Triggers after one or more moisture content values change.", "fields": { "threshold": { - "description": "[%key:component::moisture::common::trigger_threshold_changed_description%]", "name": "[%key:component::moisture::common::trigger_threshold_name%]" } }, @@ -77,8 +63,10 @@ "description": "Triggers after one or more moisture sensors stop detecting moisture.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::trigger_behavior_description%]", "name": "[%key:component::moisture::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::moisture::common::trigger_for_name%]" } }, "name": "Moisture cleared" @@ -87,11 +75,12 @@ "description": "Triggers after one or more moisture content values cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::trigger_behavior_description%]", "name": "[%key:component::moisture::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::moisture::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::moisture::common::trigger_threshold_crossed_description%]", "name": "[%key:component::moisture::common::trigger_threshold_name%]" } }, @@ -101,8 +90,10 @@ "description": "Triggers after one or more moisture sensors start detecting moisture.", "fields": { "behavior": { - "description": "[%key:component::moisture::common::trigger_behavior_description%]", "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..040ce4c1857 100644 --- a/homeassistant/components/moisture/triggers.yaml +++ b/homeassistant/components/moisture/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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..92fd79170d9 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 diff --git a/homeassistant/components/monoprice/__init__.py b/homeassistant/components/monoprice/__init__.py index 1f5df2ca194..1ebbeb2475b 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] 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..fe3e158e163 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.""" @@ -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..b43a8dab4f4 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 ( 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 cf810f0065c..4ce7c7fa2d1 100644 --- a/homeassistant/components/motion/strings.json +++ b/homeassistant/components/motion/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted motion sensors.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted motion sensors to trigger on.", - "trigger_behavior_name": "Behavior" + "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_detected": { "description": "Tests if one or more motion sensors are detecting motion.", "fields": { "behavior": { - "description": "[%key:component::motion::common::condition_behavior_description%]", "name": "[%key:component::motion::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::motion::common::condition_for_name%]" } }, "name": "Motion is detected" @@ -20,36 +22,25 @@ "description": "Tests if one or more motion sensors are not detecting motion.", "fields": { "behavior": { - "description": "[%key:component::motion::common::condition_behavior_description%]", "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": { "description": "Triggers after one or more motion sensors stop detecting motion.", "fields": { "behavior": { - "description": "[%key:component::motion::common::trigger_behavior_description%]", "name": "[%key:component::motion::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::motion::common::trigger_for_name%]" } }, "name": "Motion cleared" @@ -58,8 +49,10 @@ "description": "Triggers after one or more motion sensors start detecting motion.", "fields": { "behavior": { - "description": "[%key:component::motion::common::trigger_behavior_description%]", "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..39abe53c8a3 100644 --- a/homeassistant/components/motion/triggers.yaml +++ b/homeassistant/components/motion/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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..e67a619e322 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=hass-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..5dd615203d2 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 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/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..1a5220b3dea 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 diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index f18891c1d8c..792b276259c 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 diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index d8036f8758f..34fe7565e54 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 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..d2b5eba82cc 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 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/switch.py b/homeassistant/components/motioneye/switch.py index 09aea463838..32217abf746 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 diff --git a/homeassistant/components/motionmount/__init__.py b/homeassistant/components/motionmount/__init__.py index 9c2ac6fa180..d19bea7ccf5 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 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..730e1a3a9ff 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 diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index fb3d84041be..665f3431286 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,12 @@ 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_PROTOCOL, + SERVICE_RELOAD, +) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( ConfigValidationError, @@ -29,6 +32,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 @@ -75,12 +79,14 @@ from .const import ( DEFAULT_DISCOVERY, DEFAULT_ENCODING, DEFAULT_PREFIX, + DEFAULT_PROTOCOL, DEFAULT_QOS, DEFAULT_RETAIN, DOMAIN, ENTITY_PLATFORMS, ENTRY_OPTION_FIELDS, MQTT_CONNECTION_STATE, + PROTOCOL_311, TEMPLATE_ERRORS, Platform, ) @@ -426,6 +432,26 @@ 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)) != DEFAULT_PROTOCOL: + broker: str = entry.data[CONF_BROKER] + async_create_issue( + hass, + DOMAIN, + "protocol_5_migration", + issue_domain=DOMAIN, + is_fixable=True, + 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 diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 4cc391e0ca7..3ef84762be7 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -18,6 +18,8 @@ ABBREVIATIONS = { "bri_stat_t": "brightness_state_topic", "bri_tpl": "brightness_template", "bri_val_tpl": "brightness_value_template", + "cln_segmnts_cmd_t": "clean_segments_command_topic", + "cln_segmnts_cmd_tpl": "clean_segments_command_template", "clr_temp_cmd_tpl": "color_temp_command_template", "clrm_stat_t": "color_mode_state_topic", "clrm_val_tpl": "color_mode_value_template", @@ -255,6 +257,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..26b9e588cf5 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 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..e4cbe843633 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 @@ -18,6 +16,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 ( @@ -45,11 +45,11 @@ from homeassistant.helpers.dispatcher import ( 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 +66,6 @@ from .const import ( DEFAULT_ENCODING, DEFAULT_KEEPALIVE, DEFAULT_PORT, - DEFAULT_PROTOCOL, DEFAULT_QOS, DEFAULT_TRANSPORT, DEFAULT_WILL, @@ -77,6 +76,7 @@ from .const import ( MQTT_PROCESSED_SUBSCRIPTIONS, PROTOCOL_5, PROTOCOL_31, + PROTOCOL_311, TRANSPORT_WEBSOCKETS, ) from .models import ( @@ -89,13 +89,6 @@ 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__) MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails @@ -221,7 +214,6 @@ def async_on_subscribe_done( ) -@bind_hass async def async_subscribe( hass: HomeAssistant, topic: str, @@ -273,7 +265,6 @@ def async_subscribe_internal( return client.async_subscribe(topic, msg_callback, qos, encoding, job_type) -@bind_hass def subscribe( hass: HomeAssistant, topic: str, @@ -328,15 +319,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: @@ -425,7 +413,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 +551,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. @@ -704,7 +694,6 @@ class MQTT: 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 @@ -762,7 +751,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: @@ -1264,9 +1252,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 +1296,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 +1342,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..80529b2487b 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 @@ -24,6 +22,7 @@ from cryptography.hazmat.primitives.serialization import ( load_pem_private_key, ) from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate +import paho.mqtt.client as mqtt import voluptuous as vol import yaml @@ -477,7 +476,7 @@ _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/" @@ -1133,11 +1132,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 +1167,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 +1177,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 @@ -4068,6 +4074,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, @@ -4296,6 +4303,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 +4315,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, @@ -4984,7 +4993,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 +5044,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 @@ -5169,6 +5182,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" @@ -5357,12 +5372,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, @@ -5468,10 +5480,6 @@ 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 diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 7244a41e975..e3e2cc0634d 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -1,7 +1,5 @@ """Support for MQTT platform config setup.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.const import ( @@ -37,6 +35,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,6 +53,7 @@ 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]), diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 57d335685eb..22fdd9178b2 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -347,14 +347,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 +401,8 @@ ENTITY_PLATFORMS = [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DATE, + Platform.DATETIME, Platform.DEVICE_TRACKER, Platform.EVENT, Platform.FAN, @@ -417,6 +419,7 @@ ENTITY_PLATFORMS = [ Platform.SIREN, Platform.SWITCH, Platform.TEXT, + Platform.TIME, Platform.UPDATE, Platform.VACUUM, Platform.VALVE, @@ -432,6 +435,8 @@ SUPPORTED_COMPONENTS = ( "camera", "climate", "cover", + "date", + "datetime", "device_automation", "device_tracker", "event", @@ -450,6 +455,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..5a46cf0ca55 --- /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, + 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__) + +CONF_TIMEZONE = "timezone" + +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..133cba487c7 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 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..563db49b1d0 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 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..7fb4a619e84 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, @@ -47,7 +49,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) @@ -565,7 +570,28 @@ 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 ) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 86fd7cd5824..5b22c4acb8a 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 @@ -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__) @@ -238,7 +241,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 +390,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) @@ -713,34 +718,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 +972,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 +1040,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: @@ -1411,7 +1388,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 +1399,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 +1427,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 +1474,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 +1492,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 +1508,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) @@ -1577,7 +1601,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..50c6c9160d4 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 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..5e6c4a54814 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 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 6b1db79e269..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 @@ -146,7 +144,6 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED - _fixed_color_mode: ColorMode | str | None = None _flash_times: dict[str, int | None] _topic: dict[str, str | None] _optimistic: bool @@ -190,6 +187,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_supported_features |= ( config[CONF_TRANSITION] and LightEntityFeature.TRANSITION ) + self._attr_color_mode = ColorMode.UNKNOWN if supported_color_modes := self._config.get(CONF_SUPPORTED_COLOR_MODES): self._attr_supported_color_modes = supported_color_modes if self.supported_color_modes and len(self.supported_color_modes) == 1: @@ -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..29bcb208979 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,6 +9,8 @@ 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 @@ -26,8 +26,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 @@ -400,6 +398,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 diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py index 0b6dbce38b4..f12484a2f34 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 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..8538515118e 100644 --- a/homeassistant/components/mqtt/repairs.py +++ b/homeassistant/components/mqtt/repairs.py @@ -1,17 +1,21 @@ """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.const import CONF_PORT, CONF_PROTOCOL from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .const import DOMAIN +from .config_flow import try_connection +from .const import DEFAULT_PORT, DOMAIN, PROTOCOL_5 + +URL_MQTT_BROKER_CONFIGURATION = ( + "https://www.home-assistant.io/integrations/mqtt/#broker-configuration" +) class MQTTDeviceEntryMigration(RepairsFlow): @@ -52,6 +56,55 @@ class MQTTDeviceEntryMigration(RepairsFlow): ) +class MQTTProtocolV5Migration(RepairsFlow): + """Handler to migrate to MQTT protocol version 5.""" + + def __init__(self, entry_id: str, broker: str, protocol: str) -> None: + """Initialize the flow.""" + self.entry_id = entry_id + self.broker = broker + self.protocol = protocol + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """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: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + entry = self.hass.config_entries.async_get_entry(self.entry_id) + if TYPE_CHECKING: + assert entry is not None + new_entry_data = entry.data.copy() + new_entry_data[CONF_PROTOCOL] = PROTOCOL_5 + # Try the connection with protocol version 5 + if await self.hass.async_add_executor_job( + try_connection, + {CONF_PORT: DEFAULT_PORT} | new_entry_data, + ): + self.hass.config_entries.async_update_entry(entry, data=new_entry_data) + return self.async_create_entry(data={}) + + return self.async_abort( + reason="mqtt_broker_migration_to_v5_failed", + description_placeholders={ + "broker": self.broker, + "protocol": self.protocol, + "url_mqtt_broker_configuration": URL_MQTT_BROKER_CONFIGURATION, + }, + ) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders={"broker": self.broker, "protocol": self.protocol}, + ) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -60,13 +113,13 @@ 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] + if issue_id == "protocol_5_migration": + broker: str = data["broker"] # type: ignore[assignment] + protocol: str = data["protocol"] # type: ignore[assignment] + return MQTTProtocolV5Migration(entry_id, broker, protocol) + 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..397f235eefe 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 diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 1b3ea1a7c44..f65181dcee0 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 diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 3423fc161ce..7e1e2f1b876 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 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..a6585b3b4b6 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.", @@ -57,7 +57,7 @@ "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.", + "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" @@ -1120,6 +1120,20 @@ "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": { + "fix_flow": { + "abort": { + "mqtt_broker_migration_to_v5_failed": "Migrating the broker ({broker}) protocol version from {protocol} to 5 failed, and the migration has been aborted.\n\nYour broker may not support MQTT protocol version 5.\n\nPlease [reconfigure your MQTT broker settings]({url_mqtt_broker_configuration}) or upgrade your broker to support MQTT protocol version 5 to fix this issue." + }, + "step": { + "confirm": { + "description": "Home Assistant is migrating to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will try to migrate your MQTT broker configuration to use protocol version 5 to fix this issue.", + "title": "MQTT protocol change required" + } + } + }, + "title": "Deprecated MQTT protocol {protocol} in use" + }, "subentry_migration_discovery": { "fix_flow": { "step": { 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..aeb532f1443 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 @@ -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 6896d51ef93..3ab42edcbb8 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 @@ -10,12 +8,13 @@ import voluptuous as vol from homeassistant.components import vacuum from homeassistant.components.vacuum import ( ENTITY_ID_FORMAT, + Segment, StateVacuumEntity, VacuumActivity, VacuumEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME +from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -27,13 +26,14 @@ from . import subscription from .config import MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC from .entity import MqttEntity, async_setup_entity_entry_helper -from .models import ReceiveMessage +from .models import MqttCommandTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic PARALLEL_UPDATES = 0 FAN_SPEED = "fan_speed" +SEGMENTS = "segments" STATE = "state" STATE_IDLE = "idle" @@ -52,6 +52,8 @@ POSSIBLE_STATES: dict[str, VacuumActivity] = { STATE_CLEANING: VacuumActivity.CLEANING, } +CONF_CLEAN_SEGMENTS_COMMAND_TOPIC = "clean_segments_command_topic" +CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE = "clean_segments_command_template" CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES CONF_PAYLOAD_TURN_ON = "payload_turn_on" CONF_PAYLOAD_TURN_OFF = "payload_turn_off" @@ -137,8 +139,22 @@ MQTT_VACUUM_ATTRIBUTES_BLOCKED = frozenset( MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/" -PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( +def validate_clean_area_config(config: ConfigType) -> ConfigType: + """Validate clean area configuration.""" + if CONF_CLEAN_SEGMENTS_COMMAND_TOPIC not in config: + 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" + ) + + return config + + +_BASE_SCHEMA = MQTT_BASE_SCHEMA.extend( { + vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All( cv.ensure_list, [cv.string] ), @@ -164,7 +180,10 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.ALLOW_EXTRA) +PLATFORM_SCHEMA_MODERN = vol.All(_BASE_SCHEMA, validate_clean_area_config) +DISCOVERY_SCHEMA = vol.All( + _BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_clean_area_config +) async def async_setup_entry( @@ -191,9 +210,11 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): _entity_id_format = ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_VACUUM_ATTRIBUTES_BLOCKED + _segments: list[Segment] _command_topic: str | None _set_fan_speed_topic: str | None _send_command_topic: str | None + _clean_segments_command_topic: str | None = None _payloads: dict[str, str | None] def __init__( @@ -229,6 +250,14 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): self._attr_supported_features = _strings_to_services( supported_feature_strings, STRING_TO_SERVICE ) + self._clean_segments_command_topic = config.get( + CONF_CLEAN_SEGMENTS_COMMAND_TOPIC + ) + self._clean_segments_command_template = MqttCommandTemplate( + config.get(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE), + entity=self, + ).async_render + self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] self._command_topic = config.get(CONF_COMMAND_TOPIC) self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC) @@ -262,6 +291,24 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): POSSIBLE_STATES[cast(str, state)] if payload[STATE] else None ) del payload[STATE] + if ( + (segments_payload := payload.pop(SEGMENTS, None)) + and self._clean_segments_command_topic is not None + and isinstance(segments_payload, dict) + and ( + segments := [ + Segment(id=segment_id, name=str(segment_name)) + for segment_id, segment_name in segments_payload.items() + ] + ) + ): + self._segments = segments + self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA + 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() + self._update_state_attributes(payload) @callback @@ -277,6 +324,20 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): """(Re)Subscribe to topics.""" subscription.async_subscribe_topics_internal(self.hass, self._sub_state) + async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None: + """Perform an area clean.""" + assert self._clean_segments_command_topic is not None + await self.async_publish_with_config( + self._clean_segments_command_topic, + self._clean_segments_command_template( + json_dumps(segment_ids), {"value": segment_ids} + ), + ) + + async def async_get_segments(self) -> list[Segment]: + """Return the available segments.""" + return self._segments + async def _async_publish_command(self, feature: VacuumEntityFeature) -> None: """Publish a command.""" if self._command_topic is None: diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 7c575de09de..e50a0f26057 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 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..0c997837f49 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 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..aa50de8cd63 100644 --- a/homeassistant/components/mullvad/__init__.py +++ b/homeassistant/components/mullvad/__init__.py @@ -15,6 +15,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = MullvadCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data hass.data[DOMAIN] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/mullvad/binary_sensor.py b/homeassistant/components/mullvad/binary_sensor.py index 3984b2fec08..b40facffeaf 100644 --- a/homeassistant/components/mullvad/binary_sensor.py +++ b/homeassistant/components/mullvad/binary_sensor.py @@ -29,6 +29,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Defer sensor setup to the shared sensor module.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data coordinator = hass.data[DOMAIN] async_add_entities( diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index c0d56abba2b..0848915efb0 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 @@ -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 diff --git a/homeassistant/components/music_assistant/button.py b/homeassistant/components/music_assistant/button.py index 445ef2c3e98..cef44b63243 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 diff --git a/homeassistant/components/music_assistant/config_flow.py b/homeassistant/components/music_assistant/config_flow.py index 74a6c84dd50..8f90581addf 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 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..af14f567b90 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 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..918aa82d65e --- /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 provider dependent 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..cd873e4f090 --- /dev/null +++ b/homeassistant/components/music_assistant/select.py @@ -0,0 +1,121 @@ +"""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 provider dependent 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/strings.json b/homeassistant/components/music_assistant/strings.json index 57c5e1745b4..299e7d8caa6 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": { diff --git a/homeassistant/components/music_assistant/switch.py b/homeassistant/components/music_assistant/switch.py new file mode 100644 index 00000000000..01af51bd314 --- /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 provider dependent 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 8c1347b2b04..17b074f8cd2 100644 --- a/homeassistant/components/mutesync/__init__.py +++ b/homeassistant/components/mutesync/__init__.py @@ -1,33 +1,25 @@ """The mütesync 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 MutesyncUpdateCoordinator +from .coordinator import MutesyncConfigEntry, MutesyncUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MutesyncConfigEntry) -> bool: """Set up mütesync from a config entry.""" - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - MutesyncUpdateCoordinator(hass, entry) - ) + coordinator = MutesyncUpdateCoordinator(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: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MutesyncConfigEntry) -> 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/mutesync/binary_sensor.py b/homeassistant/components/mutesync/binary_sensor.py index 66fe78e931c..34ba8100443 100644 --- a/homeassistant/components/mutesync/binary_sensor.py +++ b/homeassistant/components/mutesync/binary_sensor.py @@ -1,14 +1,13 @@ """mütesync binary sensor entities.""" from homeassistant.components.binary_sensor import BinarySensorEntity -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 MutesyncUpdateCoordinator +from .coordinator import MutesyncConfigEntry, MutesyncUpdateCoordinator SENSORS = ( "in_meeting", @@ -18,11 +17,11 @@ SENSORS = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MutesyncConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the mütesync button.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + """Set up the mütesync binary sensors.""" + coordinator = config_entry.runtime_data async_add_entities( [MuteStatus(coordinator, sensor_type) for sensor_type in SENSORS], True ) 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 03c545c7e24..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 @@ -15,18 +13,20 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, UPDATE_INTERVAL_IN_MEETING, UPDATE_INTERVAL_NOT_IN_MEETING +type MutesyncConfigEntry = ConfigEntry[MutesyncUpdateCoordinator] + _LOGGER = logging.getLogger(__name__) class MutesyncUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinator for the mütesync integration.""" - config_entry: ConfigEntry + config_entry: MutesyncConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: MutesyncConfigEntry, ) -> None: """Initialize the coordinator.""" super().__init__( diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index 031ec164ecd..b8cc24842b3 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 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..e31ae04a9db --- /dev/null +++ b/homeassistant/components/myneomitis/climate.py @@ -0,0 +1,358 @@ +"""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}' 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..994d076e832 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=hass-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..cd829813d80 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 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..892de3d59b4 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 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..d8ba29b7d78 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=hass-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..3b72054587f 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=hass-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..5d6a99c7299 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=hass-use-runtime-data discovered_nodes = hass.data[DOMAIN].setdefault( MYSENSORS_DISCOVERED_NODES.format(gateway_id), set() ) 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..be1711e7cba 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=hass-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..7e6f108f49b 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 @@ -31,6 +30,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 +52,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..78c43fc130a 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 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..770a5a35b45 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 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/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/nad/media_player.py b/homeassistant/components/nad/media_player.py index 2af8c607610..c6c73ece775 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 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..b2351f1aeb6 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 diff --git a/homeassistant/components/nasweb/alarm_control_panel.py b/homeassistant/components/nasweb/alarm_control_panel.py index 695c0168886..b8967bf42db 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 diff --git a/homeassistant/components/nasweb/climate.py b/homeassistant/components/nasweb/climate.py index 5d3b4c469bc..bc571440f87 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 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..132c90018e6 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 diff --git a/homeassistant/components/nasweb/sensor.py b/homeassistant/components/nasweb/sensor.py index 82a69b74aa6..b71d300a58b 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 diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py index a36f3062932..b328925b835 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 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..411cf17f8e6 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 diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 02d2e40b4db..8c5a5d5ed20 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 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..4a229786403 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 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..9bf98379017 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 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/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..549dc1b2341 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 diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index d55826f7ed0..ed2de696164 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 diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 4b5bee127d0..1f0d72c11c8 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 diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index cf1e67ad887..b2ca81563f7 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -1,7 +1,5 @@ """Support for Google Nest SDM climate devices.""" -from __future__ import annotations - from typing import Any, cast from google_nest_sdm.device import Device diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 0b249db7a4b..2a0487feea1 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 diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index 8241b8aa5f8..b7757c4aa88 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 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/media_source.py b/homeassistant/components/nest/media_source.py index 4c7eb87636c..4120e752580 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 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/strings.json b/homeassistant/components/nest/strings.json index e15c7f2dcb7..aa4490d03f9 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%]" } } } diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index a8e6e52d7d3..9c1eef0d09d 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 @@ -15,7 +13,6 @@ from homeassistant.components.webhook import ( async_register as webhook_register, async_unregister as webhook_unregister, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( @@ -38,12 +35,10 @@ from homeassistant.helpers.typing import ConfigType from . import api from .const import ( - AUTH, CONF_CLOUDHOOK_URL, DATA_CAMERAS, DATA_DEVICE_IDS, DATA_EVENTS, - DATA_HANDLER, DATA_HOMES, DATA_PERSONS, DATA_SCHEDULES, @@ -52,7 +47,7 @@ from .const import ( WEBHOOK_DEACTIVATION, WEBHOOK_PUSH_TYPE, ) -from .data_handler import NetatmoDataHandler +from .data_handler import NetatmoConfigEntry, NetatmoDataHandler from .webhook import async_handle_webhook _LOGGER = logging.getLogger(__name__) @@ -64,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=hass-use-runtime-data hass.data[DOMAIN] = { DATA_PERSONS: {}, DATA_DEVICE_IDS: {}, @@ -76,7 +73,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: NetatmoConfigEntry) -> bool: """Set up Netatmo from a config entry.""" try: implementation = await async_get_config_entry_implementation(hass, entry) @@ -106,14 +103,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) raise ConfigEntryAuthFailed("Token scope not valid, trigger renewal") - hass.data[DOMAIN][entry.entry_id] = { - AUTH: api.AsyncConfigEntryNetatmoAuth( - aiohttp_client.async_get_clientsession(hass), session - ) - } + auth = api.AsyncConfigEntryNetatmoAuth( + aiohttp_client.async_get_clientsession(hass), session + ) - data_handler = NetatmoDataHandler(hass, entry) - hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] = data_handler + data_handler = NetatmoDataHandler(hass, entry, auth) + entry.runtime_data = data_handler await data_handler.async_setup() async def unregister_webhook( @@ -129,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) try: - await hass.data[DOMAIN][entry.entry_id][AUTH].async_dropwebhook() + await entry.runtime_data.auth.async_dropwebhook() except pyatmo.ApiError: _LOGGER.debug( "No webhook to be dropped for %s", entry.data[CONF_WEBHOOK_ID] @@ -165,7 +160,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: - await hass.data[DOMAIN][entry.entry_id][AUTH].async_addwebhook(webhook_url) + await entry.runtime_data.auth.async_addwebhook(webhook_url) _LOGGER.debug("Register Netatmo webhook: %s", webhook_url) except pyatmo.ApiError as err: _LOGGER.error("Error during webhook registration - %s", err) @@ -199,7 +194,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: +async def async_cloudhook_generate_url( + hass: HomeAssistant, entry: NetatmoConfigEntry +) -> str: """Generate the full URL for a webhook_id.""" if CONF_CLOUDHOOK_URL not in entry.data: webhook_url = await cloud.async_create_cloudhook( @@ -211,32 +208,27 @@ async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) return str(entry.data[CONF_CLOUDHOOK_URL]) -async def async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_config_entry_updated( + hass: HomeAssistant, entry: NetatmoConfigEntry +) -> None: """Handle signals of config entry being updated.""" async_dispatcher_send(hass, f"signal-{DOMAIN}-public-update-{entry.entry_id}") -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: NetatmoConfigEntry) -> bool: """Unload a config entry.""" - data = hass.data[DOMAIN] - if CONF_WEBHOOK_ID in entry.data: webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) try: - await data[entry.entry_id][AUTH].async_dropwebhook() + await entry.runtime_data.auth.async_dropwebhook() except pyatmo.ApiError: _LOGGER.debug("No webhook to be dropped") _LOGGER.debug("Unregister Netatmo webhook") - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok and entry.entry_id in data: - data.pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: NetatmoConfigEntry) -> None: """Cleanup when entry is removed.""" if CONF_WEBHOOK_ID in entry.data and cloud.async_active_subscription(hass): try: @@ -249,10 +241,10 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, config_entry: NetatmoConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove a config entry from a device.""" - data = hass.data[DOMAIN][config_entry.entry_id][DATA_HANDLER] + data = config_entry.runtime_data modules = [m for h in data.account.homes.values() for m in h.modules] rooms = [r for h in data.account.homes.values() for r in h.rooms] diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py index c550c31c4a6..3fa58127094 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -13,7 +13,6 @@ 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 @@ -38,7 +37,7 @@ from .const import ( NETATMO_CREATE_OPENING_BINARY_SENSOR, NETATMO_CREATE_WEATHER_BINARY_SENSOR, ) -from .data_handler import SIGNAL_NAME, NetatmoDevice +from .data_handler import SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity, NetatmoWeatherModuleEntity _LOGGER = logging.getLogger(__name__) @@ -68,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 ) @@ -180,7 +165,7 @@ DEVICE_CATEGORY_BINARY_PUBLISHERS: Final[list[NetatmoDeviceCategory]] = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Netatmo weather binary sensors based on a config entry.""" diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py index e77b5188067..72a6b105642 100644 --- a/homeassistant/components/netatmo/button.py +++ b/homeassistant/components/netatmo/button.py @@ -1,27 +1,25 @@ """Support for Netatmo/Bubendorff button.""" -from __future__ import annotations - import logging from pyatmo import modules as NaModules 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 CONF_URL_CONTROL, NETATMO_CREATE_BUTTON -from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo button platform.""" @@ -58,9 +56,7 @@ class NetatmoCoverPreferredPositionButton(NetatmoModuleEntity, ButtonEntity): }, ] ) - self._attr_unique_id = ( - f"{self.device.entity_id}-{self.device_type}-preferred_position" - ) + self._attr_unique_id = f"{self.device.entity_id}-{device_type_to_str(self.device_type)}-preferred_position" @callback def async_update_callback(self) -> None: diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index e0d84784ee8..6e19d674346 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=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import Any, cast @@ -11,7 +10,6 @@ from pyatmo.event import Event as NaEvent import voluptuous as vol from homeassistant.components.camera import Camera, CameraEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform @@ -35,14 +33,16 @@ from .const import ( EVENT_TYPE_OFF, EVENT_TYPE_ON, MANUFACTURER, + NETATMO_ALIM_STATUS_ONLINE, NETATMO_CREATE_CAMERA, SERVICE_SET_CAMERA_LIGHT, SERVICE_SET_PERSON_AWAY, SERVICE_SET_PERSONS_HOME, WEBHOOK_PUSH_TYPE, ) -from .data_handler import EVENT, HOME, SIGNAL_NAME, NetatmoDevice +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__) @@ -51,7 +51,7 @@ DEFAULT_QUALITY = "high" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo camera platform.""" @@ -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( @@ -175,18 +177,16 @@ 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", + "Camera %s has received %s event, turning on and enabling streaming if applicable", data["camera_id"], event_type, ) - self._attr_is_streaming = True + if self.device_type != "NDB": + self._attr_is_streaming = True self._monitoring = True elif event_type == EVENT_TYPE_LIGHT_MODE: if data.get("sub_type"): self._light_state = data["sub_type"] - self._attr_extra_state_attributes.update( - {"light_state": self._light_state} - ) else: _LOGGER.debug( "Camera %s has received light mode event without sub_type", @@ -226,6 +226,20 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): supported_features |= CameraEntityFeature.STREAM return supported_features + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return entity specific state attributes.""" + return { + "id": self.device.entity_id, + "monitoring": self._monitoring, + "sd_status": self.device.sd_status, + "alim_status": self.device.alim_status, + "is_local": self.device.is_local, + "vpn_url": self.device.vpn_url, + "local_url": self.device.local_url, + "light_state": self._light_state, + } + async def async_turn_off(self) -> None: """Turn off camera.""" await self.device.async_monitoring_off() @@ -249,7 +263,10 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): self._attr_is_on = self.device.alim_status is not None self._attr_available = self.device.alim_status is not None - if self.device.monitoring is not None: + if self.device_type == "NDB": + self._monitoring = self.device.alim_status == NETATMO_ALIM_STATUS_ONLINE + elif self.device.monitoring is not None: + self._monitoring = self.device.monitoring self._attr_is_streaming = self.device.monitoring self._attr_motion_detection_enabled = self.device.monitoring @@ -257,19 +274,6 @@ class NetatmoCamera(NetatmoModuleEntity, Camera): self.process_events(self.device.events) ) - self._attr_extra_state_attributes.update( - { - "id": self.device.entity_id, - "monitoring": self._monitoring, - "sd_status": self.device.sd_status, - "alim_status": self.device.alim_status, - "is_local": self.device.is_local, - "vpn_url": self.device.vpn_url, - "local_url": self.device.local_url, - "light_state": self._light_state, - } - ) - def process_events(self, event_list: list[NaEvent]) -> dict: """Add meta data to events.""" events = {} diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index a74ed630a4b..f15c5515f5d 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=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from typing import Any, cast @@ -20,7 +19,6 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_HALVES, @@ -54,8 +52,9 @@ from .const import ( SERVICE_SET_TEMPERATURE_WITH_END_DATETIME, SERVICE_SET_TEMPERATURE_WITH_TIME_PERIOD, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoRoom +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoRoom from .entity import NetatmoRoomEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -120,7 +119,7 @@ NA_VALVE = DeviceType.NRV async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo energy platform.""" @@ -221,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 b33d4898832..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 @@ -9,12 +7,7 @@ import uuid import voluptuous as vol -from homeassistant.config_entries import ( - SOURCE_REAUTH, - ConfigEntry, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_SHOW_ON_MAP, CONF_UUID from homeassistant.core import callback from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv @@ -31,6 +24,7 @@ from .const import ( CONF_WEATHER_AREAS, DOMAIN, ) +from .data_handler import NetatmoConfigEntry _LOGGER = logging.getLogger(__name__) @@ -45,7 +39,7 @@ class NetatmoFlowHandler( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: NetatmoConfigEntry, ) -> OptionsFlow: """Get the options flow for this handler.""" return NetatmoOptionsFlowHandler(config_entry) @@ -99,7 +93,7 @@ class NetatmoFlowHandler( class NetatmoOptionsFlowHandler(OptionsFlow): """Handle Netatmo options.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: NetatmoConfigEntry) -> None: """Initialize Netatmo options flow.""" self.options = dict(config_entry.options) self.options.setdefault(CONF_WEATHER_AREAS, {}) diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 9a95cd36fed..b10d2309770 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -27,11 +27,9 @@ CONF_URL_WEATHER = "https://my.netatmo.com/app/weather" CONF_URL_CONTROL = "https://home.netatmo.com/control" CONF_URL_PUBLIC_WEATHER = "https://weathermap.netatmo.com/" -AUTH = "netatmo_auth" CONF_PUBLIC = "public_sensor_config" CAMERA_DATA = "netatmo_camera" HOME_DATA = "netatmo_home_data" -DATA_HANDLER = "netatmo_data_handler" SIGNAL_NAME = "signal_name" API_SCOPES_EXCLUDED_FROM_CLOUD = [ @@ -41,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" @@ -217,5 +216,15 @@ WEBHOOK_ACTIVATION = "webhook_activation" WEBHOOK_DEACTIVATION = "webhook_deactivation" WEBHOOK_NACAMERA_CONNECTION = "NACamera-connection" WEBHOOK_NOCAMERA_CONNECTION = "NOC-connection" +WEBHOOK_NDB_CONNECTION = "NDB-connection" WEBHOOK_PUSH_TYPE = "push_type" -CAMERA_CONNECTION_WEBHOOKS = [WEBHOOK_NACAMERA_CONNECTION, WEBHOOK_NOCAMERA_CONNECTION] +CAMERA_CONNECTION_WEBHOOKS = [ + WEBHOOK_NACAMERA_CONNECTION, + WEBHOOK_NOCAMERA_CONNECTION, + WEBHOOK_NDB_CONNECTION, +] + +# Alimentation status (alim_status) for cameras and door bells (NDB). +# For NDB there is no monitoring attribute in status but only alim_status. +# 2 = Full power/online for NDB (and also Correct power adapter for NACamera). +NETATMO_ALIM_STATUS_ONLINE = 2 diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index a599aacd719..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 @@ -13,21 +11,21 @@ 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 CONF_URL_CONTROL, NETATMO_CREATE_COVER -from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo cover platform.""" @@ -73,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 31845e1c0c7..856b31f1b4e 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=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from collections import deque from dataclasses import dataclass @@ -27,20 +26,20 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.event import async_track_time_interval from .const import ( - AUTH, CAMERA_CONNECTION_WEBHOOKS, DATA_PERSONS, 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, @@ -89,6 +88,8 @@ DEFAULT_INTERVALS = { } SCAN_INTERVAL = 60 +type NetatmoConfigEntry = ConfigEntry[NetatmoDataHandler] + @dataclass class NetatmoDevice: @@ -138,11 +139,16 @@ class NetatmoDataHandler: account: pyatmo.AsyncAccount _interval_factor: int - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, + hass: HomeAssistant, + config_entry: NetatmoConfigEntry, + auth: pyatmo.AbstractAsyncAuth, + ) -> None: """Initialize self.""" self.hass = hass self.config_entry = config_entry - self._auth = hass.data[DOMAIN][config_entry.entry_id][AUTH] + self.auth = auth self.publisher: dict[str, NetatmoPublisher] = {} self._queue: deque = deque() self._webhook: bool = False @@ -171,7 +177,7 @@ class NetatmoDataHandler: ) ) - self.account = pyatmo.AsyncAccount(self._auth) + self.account = pyatmo.AsyncAccount(self.auth) await self.subscribe(ACCOUNT, ACCOUNT, None) @@ -365,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(): @@ -424,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 8cb07d1f9d8..a3b4f032714 100644 --- a/homeassistant/components/netatmo/diagnostics.py +++ b/homeassistant/components/netatmo/diagnostics.py @@ -1,15 +1,11 @@ """Diagnostics support for Netatmo.""" -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 DATA_HANDLER, DOMAIN -from .data_handler import ACCOUNT, NetatmoDataHandler +from .data_handler import ACCOUNT, NetatmoConfigEntry TO_REDACT = { "access_token", @@ -32,12 +28,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: NetatmoConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data_handler: NetatmoDataHandler = hass.data[DOMAIN][config_entry.entry_id][ - DATA_HANDLER - ] + data_handler = config_entry.runtime_data return { "info": async_redact_data( diff --git a/homeassistant/components/netatmo/entity.py b/homeassistant/components/netatmo/entity.py index 2d12631a3db..e602efe7107 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=hass-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 b0dc74c2b58..6c9665ca475 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -1,21 +1,19 @@ """Support for Netatmo/Bubendorff fans.""" -from __future__ import annotations - import logging from typing import Final from pyatmo import modules as NaModules 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 CONF_URL_CONTROL, NETATMO_CREATE_FAN -from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) @@ -27,7 +25,7 @@ PRESETS = {v: k for k, v in PRESET_MAPPING.items()} async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo fan platform.""" @@ -65,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..b1b305e6709 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 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: diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 4d4c4ba9509..e4644f56dcb 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -1,14 +1,11 @@ """Support for the Netatmo camera lights.""" -from __future__ import annotations - import logging from typing import Any from pyatmo import modules as NaModules from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -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 @@ -22,7 +19,7 @@ from .const import ( NETATMO_CREATE_CAMERA_LIGHT, NETATMO_CREATE_LIGHT, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity _LOGGER = logging.getLogger(__name__) @@ -30,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo camera light platform.""" 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..320ff328596 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=hass-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 cb6675e4129..2ba8f1e0d2a 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -1,11 +1,9 @@ """Support for the Netatmo climate schedule selector.""" - -from __future__ import annotations +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging from homeassistant.components.select import SelectEntity -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 @@ -19,7 +17,7 @@ from .const import ( MANUFACTURER, NETATMO_CREATE_SELECT, ) -from .data_handler import HOME, SIGNAL_NAME, NetatmoHome +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoHome from .entity import NetatmoBaseEntity _LOGGER = logging.getLogger(__name__) @@ -27,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo energy platform schedule selector.""" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 56b8233912f..d9167c25499 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, @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -42,18 +41,27 @@ 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, - DATA_HANDLER, DOMAIN, - NETATMO_CREATE_BATTERY, + NETATMO_CREATE_CLIMATE_BATTERY_SENSOR, + NETATMO_CREATE_LEGACY_SENSOR, NETATMO_CREATE_ROOM_SENSOR, NETATMO_CREATE_SENSOR, NETATMO_CREATE_WEATHER_SENSOR, SIGNAL_NAME, ) -from .data_handler import HOME, PUBLIC, NetatmoDataHandler, NetatmoDevice, NetatmoRoom +from .data_handler import ( + HOME, + PUBLIC, + NetatmoConfigEntry, + NetatmoDataHandler, + NetatmoDevice, + NetatmoRoom, +) from .entity import ( NetatmoBaseEntity, NetatmoModuleEntity, @@ -118,11 +126,21 @@ 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", @@ -281,8 +299,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) @@ -378,64 +395,153 @@ 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( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """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: @@ -443,9 +549,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 ) @@ -456,7 +567,7 @@ async def async_setup_entry( ) device_registry = dr.async_get(hass) - data_handler = hass.data[DOMAIN][entry.entry_id][DATA_HANDLER] + data_handler = entry.runtime_data async def add_public_entities(update: bool = True) -> None: """Retrieve Netatmo public weather entities.""" @@ -513,7 +624,54 @@ 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 @@ -524,7 +682,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}" @@ -534,14 +692,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) @@ -549,28 +715,53 @@ 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"{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}-{self.device.entity_id}-{self.entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, netatmo_device.parent_id)}, @@ -590,13 +781,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, @@ -604,36 +795,47 @@ 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 9ee37c11528..0c0a1570a7f 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -1,28 +1,26 @@ """Support for Netatmo/BTicino/Legrande switches.""" -from __future__ import annotations - import logging from typing import Any from pyatmo import modules as NaModules 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 .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH -from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice +from .data_handler import HOME, SIGNAL_NAME, NetatmoConfigEntry, NetatmoDevice from .entity import NetatmoModuleEntity +from .helper import device_type_to_str _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo switch platform.""" @@ -61,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..396651b9e35 100644 --- a/homeassistant/components/netatmo/webhook.py +++ b/homeassistant/components/netatmo/webhook.py @@ -1,4 +1,5 @@ """The Netatmo integration.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import logging 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..b96cb165d83 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 diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 5372ae70bb5..b1effa7844e 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 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..b83701bf028 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 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..afb161d9126 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=hass-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..1d03d7658ad 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=hass-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 bdda0d30356..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 CONF_HOST, 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 @@ -22,15 +22,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NFAndroidTV from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = entry.data[CONF_HOST] hass.async_create_task( discovery.async_load_platform( 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/notify.py b/homeassistant/components/nfandroidtv/notify.py index c1c19a600b9..6a0b311a3af 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 diff --git a/homeassistant/components/nfandroidtv/strings.json b/homeassistant/components/nfandroidtv/strings.json index 531a6af1617..79c9648942b 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%]", diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index ac201ed2322..0082eee755c 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -1,13 +1,10 @@ """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 from nibe.heatpump import HeatPump, Model -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_IP_ADDRESS, CONF_MODEL, @@ -30,7 +27,7 @@ from .const import ( CONF_WORD_SWAP, DOMAIN, ) -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -45,7 +42,9 @@ PLATFORMS: list[Platform] = [ COIL_READ_RETRIES = 5 -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: NibeHeatpumpConfigEntry +) -> bool: """Set up Nibe Heat Pump from a config entry.""" heatpump = HeatPump(Model[entry.data[CONF_MODEL]]) @@ -83,8 +82,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = CoilCoordinator(hass, entry, heatpump, connection) - data = hass.data.setdefault(DOMAIN, {}) - data[entry.entry_id] = coordinator + entry.runtime_data = coordinator reg = dr.async_get(hass) device_entry = reg.async_get_or_create( @@ -113,9 +111,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: NibeHeatpumpConfigEntry +) -> 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/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py index d49862180bd..ee7c0d301f1 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -1,28 +1,24 @@ """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 -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 CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( BinarySensor(coordinator, coil) diff --git a/homeassistant/components/nibe_heatpump/button.py b/homeassistant/components/nibe_heatpump/button.py index 8b6c8abf359..191c0f70933 100644 --- a/homeassistant/components/nibe_heatpump/button.py +++ b/homeassistant/components/nibe_heatpump/button.py @@ -1,29 +1,26 @@ """The Nibe Heat Pump sensors.""" -from __future__ import annotations - from nibe.coil_groups import UNIT_COILGROUPS, UnitCoilGroup from nibe.exceptions import CoilNotFoundException from homeassistant.components.button import 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 homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, LOGGER -from .coordinator import CoilCoordinator +from .const import LOGGER +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data def reset_buttons(): if unit := UNIT_COILGROUPS.get(coordinator.series, {}).get("main"): diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py index 1b8a0ecc0df..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 @@ -24,31 +22,29 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( - DOMAIN, LOGGER, VALUES_COOL_WITH_ROOM_SENSOR_OFF, VALUES_MIXING_VALVE_CLOSED_STATE, VALUES_PRIORITY_COOLING, VALUES_PRIORITY_HEATING, ) -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data main_unit = UNIT_COILGROUPS[coordinator.series]["main"] 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 05e652d7f42..2d4ee9f075c 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 @@ -28,6 +26,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER +type NibeHeatpumpConfigEntry = ConfigEntry[CoilCoordinator] + class ContextCoordinator[_DataTypeT, _ContextTypeT](DataUpdateCoordinator[_DataTypeT]): """Update coordinator with context adjustments.""" @@ -73,12 +73,12 @@ class ContextCoordinator[_DataTypeT, _ContextTypeT](DataUpdateCoordinator[_DataT class CoilCoordinator(ContextCoordinator[dict[int, CoilData], int]): """Update coordinator for nibe heat pumps.""" - config_entry: ConfigEntry + config_entry: NibeHeatpumpConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, heatpump: HeatPump, connection: Connection, ) -> None: 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 59f365f52bf..a7b11041849 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -1,28 +1,24 @@ """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 -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 CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( Number(coordinator, coil) diff --git a/homeassistant/components/nibe_heatpump/select.py b/homeassistant/components/nibe_heatpump/select.py index c92c12a882a..38da54849c7 100644 --- a/homeassistant/components/nibe_heatpump/select.py +++ b/homeassistant/components/nibe_heatpump/select.py @@ -1,28 +1,24 @@ """The Nibe Heat Pump select.""" -from __future__ import annotations - from nibe.coil import Coil, CoilData from homeassistant.components.select import ENTITY_ID_FORMAT, SelectEntity -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 CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( Select(coordinator, coil) diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py index 54cd0f7ea34..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 ( @@ -11,7 +9,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -28,8 +25,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry from .entity import CoilEntity UNIT_DESCRIPTIONS = { @@ -185,12 +181,12 @@ UNIT_DESCRIPTIONS = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( Sensor(coordinator, coil, UNIT_DESCRIPTIONS.get(coil.unit)) diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 452244f05b5..8c6f3257767 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -1,30 +1,26 @@ """The Nibe Heat Pump switch.""" -from __future__ import annotations - from typing import Any from nibe.coil import Coil, CoilData from homeassistant.components.switch import ENTITY_ID_FORMAT, 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 .const import DOMAIN -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry from .entity import CoilEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( Switch(coordinator, coil) diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py index a72851e7eab..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 @@ -14,29 +12,27 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -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 homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( - DOMAIN, LOGGER, VALUES_TEMPORARY_LUX_INACTIVE, VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE, ) -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NibeHeatpumpConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up platform.""" - coordinator: CoilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data def water_heaters(): for key, group in WATER_HEATER_COILGROUPS.get(coordinator.series, ()).items(): 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/coordinator.py b/homeassistant/components/nice_go/coordinator.py index ffdd9dbd518..186429f6e41 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 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/__init__.py b/homeassistant/components/nightscout/__init__.py index 798fcf1ec9d..9e01a2712ab 100644 --- a/homeassistant/components/nightscout/__init__.py +++ b/homeassistant/components/nightscout/__init__.py @@ -16,8 +16,10 @@ from .const import DOMAIN PLATFORMS = [Platform.SENSOR] _API_TIMEOUT = SLOW_UPDATE_WARNING - 1 +type NightscoutConfigEntry = ConfigEntry[NightscoutAPI] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: NightscoutConfigEntry) -> bool: """Set up Nightscout from a config entry.""" server_url = entry.data[CONF_URL] api_key = entry.data.get(CONF_API_KEY) @@ -28,8 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (ClientError, TimeoutError, OSError) as error: raise ConfigEntryNotReady from error - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = api + entry.runtime_data = api device_registry = dr.async_get(hass) device_registry.async_get_or_create( @@ -46,10 +47,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: NightscoutConfigEntry) -> 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/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index de1dadf1143..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 @@ -10,12 +8,12 @@ from aiohttp import ClientError from py_nightscout import Api as NightscoutAPI from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DATE, UnitOfBloodGlucoseConcentration from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_DELTA, ATTR_DEVICE, ATTR_DIRECTION, DOMAIN +from . import NightscoutConfigEntry +from .const import ATTR_DELTA, ATTR_DEVICE, ATTR_DIRECTION SCAN_INTERVAL = timedelta(minutes=1) @@ -26,11 +24,11 @@ DEFAULT_NAME = "Blood Glucose" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NightscoutConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Glucose Sensor.""" - api = hass.data[DOMAIN][entry.entry_id] + api = entry.runtime_data async_add_entities([NightscoutSensor(api, "Blood Sugar", entry.unique_id)], True) 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 4bb435ea1ce..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 @@ -18,7 +16,7 @@ from .const import ( ) from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator -PLATFORMS: list[str] = [Platform.BINARY_SENSOR] +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 62162783340..b4ffc5cfb64 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -1,6 +1,4 @@ -"""NINA sensor platform.""" - -from __future__ import annotations +"""NINA binary sensor platform.""" from typing import Any @@ -88,15 +86,19 @@ class NINAMessage(NinaEntity, BinarySensorEntity): data = self._get_warning_data() return { - ATTR_HEADLINE: data.headline, - ATTR_DESCRIPTION: data.description, - ATTR_SENDER: data.sender, - ATTR_SEVERITY: data.severity, - ATTR_RECOMMENDED_ACTIONS: data.recommended_actions, - ATTR_AFFECTED_AREAS: data.affected_areas, - ATTR_WEB: data.web, + ATTR_HEADLINE: data.headline, # Deprecated, remove in 2026.11 + 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 + 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, - ATTR_SENT: data.sent, - ATTR_START: data.start, - ATTR_EXPIRES: data.expires, + ATTR_SENT: data.sent.isoformat(), # Deprecated, remove in 2026.11 + ATTR_START: data.start.isoformat() + if data.start + else "", # Deprecated, remove in 2026.11 + ATTR_EXPIRES: data.expires.isoformat() + if data.expires + else "", # Deprecated, remove in 2026.11 } diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index 2eeec4de19d..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 @@ -31,6 +29,7 @@ from .const import ( CONST_REGIONS, DOMAIN, NO_MATCH_REGEX, + SENSOR_SUFFIXES, ) @@ -243,32 +242,7 @@ class OptionsFlowHandler(OptionsFlowWithReload): user_input, self._all_region_codes_sorted ) - entity_registry = er.async_get(self.hass) - - entries = er.async_entries_for_config_entry( - entity_registry, self.config_entry.entry_id - ) - - removed_entities_slots = [ - f"{region}-{slot_id}" - for region in self.data[CONF_REGIONS] - for slot_id in range(self.data[CONF_MESSAGE_SLOTS] + 1) - if slot_id > user_input[CONF_MESSAGE_SLOTS] - ] - - removed_entities_area = [ - f"{cfg_region}-{slot_id}" - for slot_id in range(1, self.data[CONF_MESSAGE_SLOTS] + 1) - for cfg_region in self.data[CONF_REGIONS] - if cfg_region not in user_input[CONF_REGIONS] - ] - - for entry in entries: - for entity_uid in list( - set(removed_entities_slots + removed_entities_area) - ): - if entry.unique_id == entity_uid: - entity_registry.async_remove(entry.entity_id) + await self.remove_unused_entities(user_input) self.hass.config_entries.async_update_entry( self.config_entry, data=user_input @@ -287,3 +261,35 @@ class OptionsFlowHandler(OptionsFlowWithReload): data_schema=schema_with_suggested, errors=errors, ) + + async def remove_unused_entities(self, user_input: dict[str, Any]) -> None: + """Remove entities which are not used anymore.""" + entity_registry = er.async_get(self.hass) + + entries = er.async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ) + + id_type_suffix = [f"-{sensor_id}" for sensor_id in SENSOR_SUFFIXES] + [""] + + removed_entities_slots = [ + f"{region}-{slot_id}{suffix}" + for region in self.data[CONF_REGIONS] + for slot_id in range(self.data[CONF_MESSAGE_SLOTS] + 1) + for suffix in id_type_suffix + if slot_id > user_input[CONF_MESSAGE_SLOTS] + ] + + removed_entities_area = [ + f"{cfg_region}-{slot_id}{suffix}" + for slot_id in range(1, self.data[CONF_MESSAGE_SLOTS] + 1) + for cfg_region in self.data[CONF_REGIONS] + for suffix in id_type_suffix + if cfg_region not in user_input[CONF_REGIONS] + ] + + removed_uids = set(removed_entities_slots + removed_entities_area) + + for entry in entries: + if entry.unique_id in removed_uids: + entity_registry.async_remove(entry.entity_id) diff --git a/homeassistant/components/nina/const.py b/homeassistant/components/nina/const.py index 409658e4131..6953a63e9a0 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 @@ -15,6 +13,8 @@ DOMAIN: str = "nina" NO_MATCH_REGEX: str = "/(?!)/" ALL_MATCH_REGEX: str = ".*" +SEVERITY_VALUES: list[str] = ["extreme", "severe", "moderate", "minor", "unknown"] + CONF_REGIONS: str = "regions" CONF_MESSAGE_SLOTS: str = "slots" CONF_FILTERS: str = "filters" @@ -34,6 +34,17 @@ ATTR_SENT: str = "sent" ATTR_START: str = "start" ATTR_EXPIRES: str = "expires" +SENSOR_SUFFIXES: list[str] = [ + "headline", + "sender", + "severity", + "affected_areas", + "more_info_url", + "sent", + "start", + "expires", +] + CONST_LIST_A_TO_D: list[str] = ["A", "Ä", "B", "C", "D"] CONST_LIST_E_TO_H: list[str] = ["E", "F", "G", "H"] CONST_LIST_I_TO_L: list[str] = ["I", "J", "K", "L"] diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py index b41bcea55ae..627bda99d5b 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -1,9 +1,8 @@ """DataUpdateCoordinator for the nina integration.""" -from __future__ import annotations - import asyncio from dataclasses import dataclass +from datetime import datetime import re from typing import Any @@ -12,7 +11,6 @@ from pynina import ApiError, Nina from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -36,13 +34,14 @@ class NinaWarningData: headline: str description: str sender: str - severity: str + severity: str | None recommended_actions: str + affected_areas_short: str affected_areas: str - web: str - sent: str - start: str - expires: str + more_info_url: str + sent: datetime + start: datetime | None + expires: datetime | None is_valid: bool @@ -65,12 +64,6 @@ class NINADataUpdateCoordinator( ] self.area_filter: str = config_entry.data[CONF_FILTERS][CONF_AREA_FILTER] - self.device_info = DeviceInfo( - identifiers={(DOMAIN, config_entry.entry_id)}, - manufacturer="NINA", - entry_type=DeviceEntryType.SERVICE, - ) - regions: dict[str, str] = config_entry.data[CONF_REGIONS] for region in regions: self._nina.add_region(region) @@ -146,18 +139,33 @@ class NINADataUpdateCoordinator( ) continue + shortened_affected_areas: str = ( + affected_areas_string[0:250] + "..." + if len(affected_areas_string) > 250 + else affected_areas_string + ) + + severity = ( + None + if raw_warn.severity.lower() == "unknown" + else raw_warn.severity + ) + warning_data: NinaWarningData = NinaWarningData( raw_warn.id, raw_warn.headline, raw_warn.description, - raw_warn.sender, - raw_warn.severity, + raw_warn.sender or "", + severity, " ".join([str(action) for action in raw_warn.recommended_actions]), + shortened_affected_areas, affected_areas_string, raw_warn.web or "", - raw_warn.sent or "", - raw_warn.start or "", - raw_warn.expires or "", + datetime.fromisoformat(raw_warn.sent), + datetime.fromisoformat(raw_warn.start) if raw_warn.start else None, + datetime.fromisoformat(raw_warn.expires) + if raw_warn.expires + else None, raw_warn.is_valid, ) warnings_for_regions.append(warning_data) diff --git a/homeassistant/components/nina/entity.py b/homeassistant/components/nina/entity.py index 97db7c90064..c5b462fcd7a 100644 --- a/homeassistant/components/nina/entity.py +++ b/homeassistant/components/nina/entity.py @@ -1,7 +1,9 @@ """NINA common entity.""" +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import DOMAIN from .coordinator import NINADataUpdateCoordinator, NinaWarningData @@ -20,12 +22,18 @@ class NinaEntity(CoordinatorEntity[NINADataUpdateCoordinator]): self._region = region self._warning_index = slot_id - 1 + self._region_name = region_name self._attr_translation_placeholders = { - "region_name": region_name, "slot_id": str(slot_id), } - self._attr_device_info = coordinator.device_info + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._region)}, + manufacturer="NINA", + name=self._region_name, + entry_type=DeviceEntryType.SERVICE, + ) def _get_active_warnings_count(self) -> int: """Return the number of active warnings for the region.""" diff --git a/homeassistant/components/nina/quality_scale.yaml b/homeassistant/components/nina/quality_scale.yaml index 1d405b9e8cb..45d3e909d5e 100644 --- a/homeassistant/components/nina/quality_scale.yaml +++ b/homeassistant/components/nina/quality_scale.yaml @@ -62,23 +62,17 @@ rules: docs-supported-devices: status: exempt comment: | - This integration does not use devices. - docs-supported-functions: todo + This integration exposes Home Assistant devices only for logical grouping and does not integrate specific physical devices that need to be documented as supported hardware. + docs-supported-functions: done docs-troubleshooting: todo docs-use-cases: todo dynamic-devices: done - entity-category: todo - entity-device-class: - status: todo - comment: | - Extract attributes into own entities. + entity-category: done + entity-device-class: done entity-disabled-by-default: done - entity-translations: todo + entity-translations: done exception-translations: todo - icon-translations: - status: exempt - comment: | - This integration does not custom icons. + icon-translations: todo reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/nina/sensor.py b/homeassistant/components/nina/sensor.py new file mode 100644 index 00000000000..a25f5bc6ab1 --- /dev/null +++ b/homeassistant/components/nina/sensor.py @@ -0,0 +1,157 @@ +"""NINA sensor platform.""" + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import CONF_MESSAGE_SLOTS, CONF_REGIONS, SENSOR_SUFFIXES, SEVERITY_VALUES +from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator, NinaWarningData +from .entity import NinaEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class NinaSensorEntityDescription(SensorEntityDescription): + """Describes NINA sensor entity.""" + + value_fn: Callable[[NinaWarningData], str | datetime | None] + + +SENSOR_TYPES: tuple[NinaSensorEntityDescription, ...] = ( + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[0], + translation_key="headline", + value_fn=lambda data: data.headline, + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[1], + translation_key="sender", + value_fn=lambda data: data.sender, + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[2], + options=SEVERITY_VALUES, + device_class=SensorDeviceClass.ENUM, + translation_key="severity", + value_fn=lambda data: ( + data.severity.lower() if data.severity is not None else None + ), + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[3], + translation_key="affected_areas", + value_fn=lambda data: data.affected_areas_short, + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[4], + translation_key="more_info_url", + value_fn=lambda data: data.more_info_url, + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[5], + translation_key="sent", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.sent, + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[6], + translation_key="start", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.start, + ), + NinaSensorEntityDescription( + key=SENSOR_SUFFIXES[7], + translation_key="expires", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.expires, + ), +) + + +def create_sensors_for_warning( + coordinator: NINADataUpdateCoordinator, region: str, region_name: str, slot_id: int +) -> Sequence[NinaSensor]: + """Create sensors for a warning.""" + return [ + NinaSensor( + coordinator, + region, + region_name, + slot_id, + description, + ) + for description in SENSOR_TYPES + ] + + +async def async_setup_entry( + _: HomeAssistant, + config_entry: NinaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the NINA sensor platform.""" + + coordinator = config_entry.runtime_data + + regions: dict[str, str] = config_entry.data[CONF_REGIONS] + message_slots: int = config_entry.data[CONF_MESSAGE_SLOTS] + + entities = [ + create_sensors_for_warning(coordinator, ent, regions[ent], i + 1) + for ent in coordinator.data + for i in range(message_slots) + ] + + async_add_entities( + [entity for slot_entities in entities for entity in slot_entities] + ) + + +class NinaSensor(NinaEntity, SensorEntity): + """Representation of a NINA sensor.""" + + _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC + + entity_description: NinaSensorEntityDescription + + def __init__( + self, + coordinator: NINADataUpdateCoordinator, + region: str, + region_name: str, + slot_id: int, + description: NinaSensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator, region, region_name, slot_id) + + self.entity_description = description + + self._attr_unique_id = f"{region}-{slot_id}-{self.entity_description.key}" + + @property + def available(self) -> bool: + """Return if entity is available.""" + if self._get_active_warnings_count() <= self._warning_index: + return False + + return self._get_warning_data().is_valid and super().available + + @property + def native_value(self) -> str | datetime | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._get_warning_data()) diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index 711ca9d3715..2e36e2fd61e 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -48,7 +48,39 @@ "entity": { "binary_sensor": { "warning": { - "name": "Warning: {region_name} {slot_id}" + "name": "Warning {slot_id}" + } + }, + "sensor": { + "affected_areas": { + "name": "Affected areas {slot_id}" + }, + "expires": { + "name": "Expires {slot_id}" + }, + "headline": { + "name": "Headline {slot_id}" + }, + "more_info_url": { + "name": "More information URL {slot_id}" + }, + "sender": { + "name": "Sender {slot_id}" + }, + "sent": { + "name": "Sent {slot_id}" + }, + "severity": { + "name": "Severity {slot_id}", + "state": { + "extreme": "Extreme", + "minor": "Minor", + "moderate": "Moderate", + "severe": "Severe" + } + }, + "start": { + "name": "Start {slot_id}" } } }, 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..b6adb852c0f 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 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 53fb013cf64..fd1fe831b68 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.3"] + "requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.4"] } 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/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..ca9bd803da3 100644 --- a/homeassistant/components/nintendo_parental_controls/sensor.py +++ b/homeassistant/components/nintendo_parental_controls/sensor.py @@ -1,7 +1,5 @@ """Sensor platform for Nintendo parental controls.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime 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 fda6ec08b45..d65eb20a280 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 @@ -39,6 +37,8 @@ from .const import ( TRACKER_SCAN_INTERVAL, ) +type NmapTrackerConfigEntry = ConfigEntry[NmapDeviceScanner] + # 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 @@ -85,23 +85,25 @@ class NmapTrackedDevices: _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NmapTrackerConfigEntry) -> bool: """Set up Nmap Tracker from a config entry.""" domain_data = hass.data.setdefault(DOMAIN, {}) devices = domain_data.setdefault(NMAP_TRACKED_DEVICES, NmapTrackedDevices()) - scanner = domain_data[entry.entry_id] = NmapDeviceScanner(hass, entry, devices) + scanner = NmapDeviceScanner(hass, entry, devices) await scanner.async_setup() + entry.runtime_data = scanner 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: NmapTrackerConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: _async_untrack_devices(hass, entry) - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok @@ -143,6 +145,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=hass-use-runtime-data devices = hass.data[DOMAIN][NMAP_TRACKED_DEVICES] remove_mac_addresses = [ mac_address diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 7bde59b768e..fca87db0804 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 @@ -16,7 +14,6 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.components.network import MDNS_TARGET_IP from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -26,6 +23,7 @@ from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import TextSelector, TextSelectorConfig from homeassistant.helpers.typing import VolDictType +from . import NmapTrackerConfigEntry from .const import ( CONF_HOME_INTERVAL, CONF_HOSTS_EXCLUDE, @@ -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=hass-config-flow-polling-field vol.Optional( CONF_SCAN_INTERVAL, default=user_input.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL), @@ -184,7 +184,7 @@ async def _async_build_schema_with_user_input( class OptionsFlowHandler(OptionsFlowWithReload): """Handle an option flow for nmap tracker.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: NmapTrackerConfigEntry) -> None: """Initialize options flow.""" self.options = dict(config_entry.options) @@ -259,6 +259,8 @@ class NmapTrackerConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: + def async_get_options_flow( + config_entry: NmapTrackerConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index afac3f06435..edc86c231bd 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -1,29 +1,31 @@ """Support for scanning a network with nmap.""" -from __future__ import annotations - import logging from typing import Any from homeassistant.components.device_tracker import ScannerEntity -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 NmapDevice, NmapDeviceScanner, short_hostname, signal_device_update -from .const import DOMAIN +from . import ( + NmapDevice, + NmapDeviceScanner, + NmapTrackerConfigEntry, + short_hostname, + signal_device_update, +) _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NmapTrackerConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Nmap Tracker component.""" - nmap_tracker = hass.data[DOMAIN][entry.entry_id] + nmap_tracker = entry.runtime_data @callback def device_new(mac_address): diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py index 4a2783143ca..b141cf3114a 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=hass-use-runtime-data hass.data[DOMAIN] = station_response.stations return True diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 1bb83e142d5..d67f9f57055 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 diff --git a/homeassistant/components/nmbs/strings.json b/homeassistant/components/nmbs/strings.json index 918087b8d33..d7bf9ad6209 100644 --- a/homeassistant/components/nmbs/strings.json +++ b/homeassistant/components/nmbs/strings.json @@ -7,7 +7,7 @@ "same_station": "[%key:component::nmbs::config::error::same_station%]" }, "error": { - "same_station": "Departure and arrival station can not be the same." + "same_station": "The departure and arrival station cannot be the same." }, "step": { "user": { 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 7c886c534cb..f8d421b9e27 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -1,35 +1,75 @@ """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, + EVENT_HOMEASSISTANT_STOP, + Platform, +) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.util import dt as dt_util -from .const import CONF_AUTO_DISCOVERED, CONF_SERIAL, DOMAIN +from .const import ( + ATTR_HARDWARE_VERSION, + ATTR_SOFTWARE_VERSION, + CONF_OVERRIDE_TYPE, + CONF_SERIAL, + DOMAIN, + NOBO_MANUFACTURER, +) PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] +type NoboHubConfigEntry = ConfigEntry[nobo] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> bool: """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] - hass.data.setdefault(DOMAIN, {}) + 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.""" @@ -38,7 +78,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) ) - hass.data[DOMAIN][entry.entry_id] = hub + entry.runtime_data = hub + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, hub.hub_serial)}, + 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) @@ -47,12 +99,33 @@ 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: NoboHubConfigEntry) -> bool: """Unload a config entry.""" - hub: nobo = hass.data[DOMAIN][entry.entry_id] if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - await hub.stop() - hass.data[DOMAIN].pop(entry.entry_id) + 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 018f3e2b06a..427185d977b 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -1,7 +1,5 @@ """Python Control of Nobø Hub - Nobø Energy Control.""" -from __future__ import annotations - from typing import Any from pynobo import nobo @@ -17,13 +15,13 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util +from . import NoboHubConfigEntry from .const import ( ATTR_SERIAL, ATTR_TEMP_COMFORT_C, @@ -32,6 +30,9 @@ from .const import ( DOMAIN, OVERRIDE_TYPE_NOW, ) +from .entity import NoboBaseEntity + +PARALLEL_UPDATES = 0 SUPPORT_FLAGS = ( ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -45,13 +46,13 @@ MAX_TEMPERATURE = 30 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NoboHubConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nobø Ecohub platform from UI configuration.""" # Setup connection with hub - hub: nobo = hass.data[DOMAIN][config_entry.entry_id] + hub = config_entry.runtime_data override_type = ( nobo.API.OVERRIDE_TYPE_NOW @@ -63,7 +64,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 +72,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 @@ -81,12 +81,13 @@ class NoboZone(ClimateEntity): _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 + # 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,14 +98,6 @@ 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: @@ -139,8 +132,6 @@ class NoboZone(ClimateEntity): 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 ) @@ -152,6 +143,11 @@ class NoboZone(ClimateEntity): @callback def _read_state(self) -> None: """Read the current state from the hub. These are only local calls.""" + 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 +174,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 05ece456f15..fd90f7c0ed4 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 @@ -9,7 +7,6 @@ from pynobo import nobo import voluptuous as vol from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -17,9 +14,10 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig +from . import NoboHubConfigEntry from .const import ( - CONF_AUTO_DISCOVERED, CONF_OVERRIDE_TYPE, CONF_SERIAL, DOMAIN, @@ -35,6 +33,7 @@ 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.""" @@ -83,7 +82,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 +111,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 +130,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 +140,6 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_SERIAL: serial, CONF_IP_ADDRESS: ip_address, - CONF_AUTO_DISCOVERED: auto_discovered, }, ) @@ -153,11 +151,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): @@ -172,7 +177,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: NoboHubConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -205,8 +210,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..8b231f76a7a 100644 --- a/homeassistant/components/nobo_hub/const.py +++ b/homeassistant/components/nobo_hub/const.py @@ -2,11 +2,10 @@ 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" diff --git a/homeassistant/components/nobo_hub/entity.py b/homeassistant/components/nobo_hub/entity.py new file mode 100644 index 00000000000..60ecae73436 --- /dev/null +++ b/homeassistant/components/nobo_hub/entity.py @@ -0,0 +1,38 @@ +"""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: + """Read the current state from the hub. Must be overridden.""" + raise NotImplementedError diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index 566ff88abac..e252cd60bef 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -1,17 +1,15 @@ """Python Control of Nobø Hub - Nobø Energy Control.""" -from __future__ import annotations - from pynobo import nobo from homeassistant.components.select import SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_NAME 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 . import NoboHubConfigEntry from .const import ( ATTR_HARDWARE_VERSION, ATTR_SERIAL, @@ -21,17 +19,20 @@ from .const import ( NOBO_MANUFACTURER, OVERRIDE_TYPE_NOW, ) +from .entity import NoboBaseEntity + +PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NoboHubConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" # Setup connection with hub - hub: nobo = hass.data[DOMAIN][config_entry.entry_id] + hub = config_entry.runtime_data override_type = ( nobo.API.OVERRIDE_TYPE_NOW @@ -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] @@ -101,31 +92,25 @@ class NoboGlobalSelector(SelectEntity): @callback def _read_state(self) -> None: + """Read the current state from the hub. These are only local calls.""" 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._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 +119,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] @@ -158,6 +135,12 @@ class NoboProfileSelector(SelectEntity): @callback def _read_state(self) -> None: + """Read the current state from the hub. These are only local calls.""" + 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 +149,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 6a394f23f4c..1b8f13ae07f 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 ( @@ -9,25 +7,28 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODEL, ATTR_NAME, UnitOfTemperature 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 . 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( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NoboHubConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up any temperature sensors connected to the Nobø Ecohub.""" # Setup connection with hub - hub: nobo = hass.data[DOMAIN][config_entry.entry_id] + hub = config_entry.runtime_data async_add_entities( NoboTemperatureSensor(component["serial"], hub) @@ -36,20 +37,18 @@ 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 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 +66,16 @@ 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.""" + 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() diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json index 5323ee23965..1b04b8c3fa8 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -5,6 +5,7 @@ }, "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 +16,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 +58,29 @@ } } }, + "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." + } + }, "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..67d141c15f2 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 diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py index b000bc17887..574ab90fa3c 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 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 79f5d951e7e..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 @@ -9,7 +7,6 @@ from uuid import UUID from aionotion.errors import InvalidCredentialsError, NotionError from aionotion.listener.models import ListenerKind -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -18,7 +15,6 @@ from homeassistant.helpers import entity_registry as er from .const import ( CONF_REFRESH_TOKEN, CONF_USER_UUID, - DOMAIN, LOGGER, SENSOR_BATTERY, SENSOR_DOOR, @@ -31,7 +27,7 @@ from .const import ( SENSOR_TEMPERATURE, SENSOR_WINDOW_HINGED, ) -from .coordinator import NotionDataUpdateCoordinator +from .coordinator import NotionConfigEntry, NotionDataUpdateCoordinator from .util import async_get_client_with_credentials, async_get_client_with_refresh_token PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -67,7 +63,7 @@ def is_uuid(value: str) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NotionConfigEntry) -> bool: """Set up Notion as a config entry.""" entry_updates: dict[str, Any] = {"data": {**entry.data}} @@ -119,8 +115,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = NotionDataUpdateCoordinator(hass, entry=entry, client=client) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator @callback def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None: @@ -157,10 +152,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: NotionConfigEntry) -> bool: """Unload a Notion 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/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py index 5552305e867..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 @@ -12,13 +10,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 .const import ( - DOMAIN, LOGGER, SENSOR_BATTERY, SENSOR_DOOR, @@ -30,7 +26,7 @@ from .const import ( SENSOR_SMOKE_CO, SENSOR_WINDOW_HINGED, ) -from .coordinator import NotionDataUpdateCoordinator +from .coordinator import NotionConfigEntry from .entity import NotionEntity, NotionEntityDescription @@ -108,11 +104,11 @@ BINARY_SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NotionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Notion sensors based on a config entry.""" - coordinator: NotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ 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/coordinator.py b/homeassistant/components/notion/coordinator.py index d77bfa95f47..136644bcfb5 100644 --- a/homeassistant/components/notion/coordinator.py +++ b/homeassistant/components/notion/coordinator.py @@ -28,10 +28,12 @@ DATA_USER_PREFERENCES = "user_preferences" DEFAULT_SCAN_INTERVAL = timedelta(minutes=1) +type NotionConfigEntry = ConfigEntry[NotionDataUpdateCoordinator] + @callback def _async_register_new_bridge( - hass: HomeAssistant, entry: ConfigEntry, bridge: Bridge + hass: HomeAssistant, entry: NotionConfigEntry, bridge: Bridge ) -> None: """Register a new bridge.""" if name := bridge.name: @@ -55,7 +57,7 @@ class NotionData: """Define a manager class for Notion data.""" hass: HomeAssistant - entry: ConfigEntry + entry: NotionConfigEntry # Define a dict of bridges, indexed by bridge ID (an integer): bridges: dict[int, Bridge] = field(default_factory=dict) @@ -104,13 +106,13 @@ class NotionData: class NotionDataUpdateCoordinator(DataUpdateCoordinator[NotionData]): """Define a Notion data coordinator.""" - config_entry: ConfigEntry + config_entry: NotionConfigEntry def __init__( self, hass: HomeAssistant, *, - entry: ConfigEntry, + entry: NotionConfigEntry, client: Client, ) -> None: """Initialize.""" diff --git a/homeassistant/components/notion/diagnostics.py b/homeassistant/components/notion/diagnostics.py index 424e5f7d0ac..12641164441 100644 --- a/homeassistant/components/notion/diagnostics.py +++ b/homeassistant/components/notion/diagnostics.py @@ -1,16 +1,13 @@ """Diagnostics support for Notion.""" -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_EMAIL, CONF_UNIQUE_ID, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import CONF_REFRESH_TOKEN, CONF_USER_UUID, DOMAIN -from .coordinator import NotionDataUpdateCoordinator +from .const import CONF_REFRESH_TOKEN, CONF_USER_UUID +from .coordinator import NotionConfigEntry CONF_DEVICE_KEY = "device_key" CONF_HARDWARE_ID = "hardware_id" @@ -34,10 +31,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: NotionConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: NotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return 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/notion/sensor.py b/homeassistant/components/notion/sensor.py index 24496c8391a..bae095ad1a4 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -10,13 +10,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, SENSOR_MOLD, SENSOR_TEMPERATURE -from .coordinator import NotionDataUpdateCoordinator +from .const import SENSOR_MOLD, SENSOR_TEMPERATURE +from .coordinator import NotionConfigEntry from .entity import NotionEntity, NotionEntityDescription @@ -43,11 +42,11 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NotionConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Notion sensors based on a config entry.""" - coordinator: NotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ 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/commands.py b/homeassistant/components/novy_cooker_hood/commands.py new file mode 100644 index 00000000000..783206e9427 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/commands.py @@ -0,0 +1,14 @@ +"""Helpers for loading Novy cooker-hood RF commands.""" + +from typing import Final + +from rf_protocols import CodeCollection, get_codes + +COMMAND_LIGHT: Final = "light" +COMMAND_PLUS: Final = "plus" +COMMAND_MINUS: Final = "minus" + + +def get_codes_for_code(code: int) -> CodeCollection: + """Return the bundled `rf-protocols` collection for a Novy cooker-hood code.""" + return get_codes(f"novy/cooker_hood/code_{code}") 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..7a7d8d93916 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/config_flow.py @@ -0,0 +1,134 @@ +"""Config flow for the Novy Cooker Hood integration.""" + +import asyncio +from typing import Any + +import voluptuous as vol + +from homeassistant.components.radio_frequency import ( + async_get_transmitters, + async_send_command, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from .commands import COMMAND_LIGHT, get_codes_for_code +from .const import ( + CODE_MAX, + CODE_MIN, + CONF_CODE, + 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.""" + 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") + + 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]) + await self.async_set_unique_id(f"{entity_entry.id}_{code}") + 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="user", + 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 + try: + command = await get_codes_for_code(self._code).async_load_command( + COMMAND_LIGHT + ) + 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 code selection step.""" + return await self.async_step_user() + + async def async_step_finish( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Create the config entry.""" + assert self._transmitter_id is not None + return self.async_create_entry( + title="Novy Cooker Hood", + data={ + CONF_TRANSMITTER: self._transmitter_id, + CONF_CODE: self._code, + }, + ) diff --git a/homeassistant/components/novy_cooker_hood/const.py b/homeassistant/components/novy_cooker_hood/const.py new file mode 100644 index 00000000000..e46aa3f3e1d --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/const.py @@ -0,0 +1,19 @@ +"""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" +CONF_CODE: Final = "code" + +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/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..323977d59c8 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -0,0 +1,141 @@ +"""Fan platform for the Novy Cooker Hood (calibrated speed control).""" + +import math +from typing import Any + +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.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 .commands import COMMAND_MINUS, COMMAND_PLUS, get_codes_for_code +from .const import CONF_CODE, 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._codes = get_codes_for_code(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 = await self._codes.async_load_command(COMMAND_PLUS) + 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 = await self._codes.async_load_command(COMMAND_MINUS) + 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 = await self._codes.async_load_command(COMMAND_MINUS) + for _ in range(SPEED_COUNT): + await self._async_send(minus) + if level > 0: + plus = await self._codes.async_load_command(COMMAND_PLUS) + for _ in range(level): + await self._async_send(plus) + self._level = level + self.async_write_ha_state() + + async def _async_send(self, command: Any) -> 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..53853d28cdf --- /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 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 .commands import COMMAND_LIGHT, get_codes_for_code +from .const import CONF_CODE +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._codes = get_codes_for_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_command(COMMAND_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_command(COMMAND_LIGHT) + 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 self._codes.async_load_command(name) + 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..92a53f4c262 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/manifest.json @@ -0,0 +1,12 @@ +{ + "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": "bronze", + "requirements": ["rf-protocols==2.2.0"] +} 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..93a6fc2a244 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/quality_scale.yaml @@ -0,0 +1,109 @@ +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: 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 authenticate. + test-coverage: done + # 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 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: done + icon-translations: + status: exempt + comment: | + The light entity 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/novy_cooker_hood/strings.json b/homeassistant/components/novy_cooker_hood/strings.json new file mode 100644 index 00000000000..1a546af6fb9 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/strings.json @@ -0,0 +1,60 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_compatible_transmitters": "No radio frequency transmitter supports 433.92 MHz OOK transmissions. Please add a compatible transmitter first.", + "no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first." + }, + "step": { + "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": "Radio frequency transmitter" + }, + "data_description": { + "code": "The code your hood is paired with (1-10). Code 1 is the factory default.", + "transmitter": "The radio frequency transmitter used to control the Novy cooker hood." + }, + "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..3a59260a4d7 100644 --- a/homeassistant/components/nrgkick/device_tracker.py +++ b/homeassistant/components/nrgkick/device_tracker.py @@ -1,7 +1,5 @@ """Device tracker platform for NRGkick.""" -from __future__ import annotations - from typing import Any, Final from homeassistant.components.device_tracker import SourceType 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 3261650b824..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 @@ -87,19 +85,18 @@ NUMBERS: tuple[NRGkickNumberEntityDescription, ...] = ( int(value) ), ), - NRGkickNumberEntityDescription( - key="phase_count", - translation_key="phase_count", - native_min_value=1, - native_max_value=3, - native_step=1, - mode=NumberMode.SLIDER, - value_fn=lambda data: data.control.get(CONTROL_KEY_PHASE_COUNT), - set_value_fn=lambda coordinator, value: coordinator.api.set_phase_count( - int(value) - ), - max_value_fn=_get_phase_count_max, - ), +) + +PHASE_COUNT_DESCRIPTION = NRGkickNumberEntityDescription( + key="phase_count", + translation_key="phase_count", + native_min_value=1, + native_max_value=3, + native_step=1, + mode=NumberMode.SLIDER, + value_fn=lambda data: data.control.get(CONTROL_KEY_PHASE_COUNT), + set_value_fn=lambda coordinator, value: coordinator.api.set_phase_count(int(value)), + max_value_fn=_get_phase_count_max, ) @@ -111,9 +108,11 @@ async def async_setup_entry( """Set up NRGkick number entities based on a config entry.""" coordinator = entry.runtime_data - async_add_entities( + entities: list[NRGkickNumber] = [ NRGkickNumber(coordinator, description) for description in NUMBERS - ) + ] + entities.append(NRGkickPhaseCountNumber(coordinator, PHASE_COUNT_DESCRIPTION)) + async_add_entities(entities) class NRGkickNumber(NRGkickEntity, NumberEntity): @@ -153,3 +152,26 @@ class NRGkickNumber(NRGkickEntity, NumberEntity): await self._async_call_api( self.entity_description.set_value_fn(self.coordinator, value) ) + + +class NRGkickPhaseCountNumber(NRGkickNumber): + """Phase count number entity with optimistic state. + + The device briefly reports 0 phases while switching. This subclass + caches the last valid value to avoid exposing the transient state. + """ + + _last_phase_count: float | None = None + + @property + def native_value(self) -> float | None: + """Return the current value, filtering transient zeros.""" + value = super().native_value + if value is not None and value != 0: + self._last_phase_count = value + return self._last_phase_count + + async def async_set_native_value(self, value: float) -> None: + """Set phase count with optimistic update.""" + self._last_phase_count = int(value) + await super().async_set_native_value(value) 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..715a43b381e 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 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..12ec03d1195 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 diff --git a/homeassistant/components/ntfy/event.py b/homeassistant/components/ntfy/event.py index 8f5d8d7b621..f22612f10fc 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 diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index dda80fef257..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.3"] + "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..6cba53ac3b1 100644 --- a/homeassistant/components/ntfy/repairs.py +++ b/homeassistant/components/ntfy/repairs.py @@ -1,7 +1,5 @@ """Repairs for ntfy integration.""" -from __future__ import annotations - import voluptuous as vol from homeassistant import data_entry_flow 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/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/nuheat/__init__.py b/homeassistant/components/nuheat/__init__.py index 21c7ca79a1f..ca72d4906ae 100644 --- a/homeassistant/components/nuheat/__init__.py +++ b/homeassistant/components/nuheat/__init__.py @@ -6,13 +6,12 @@ import logging import nuheat import requests -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_SERIAL_NUMBER, DOMAIN, PLATFORMS -from .coordinator import NuHeatCoordinator +from .const import CONF_SERIAL_NUMBER, PLATFORMS +from .coordinator import NuHeatConfigEntry, NuHeatCoordinator _LOGGER = logging.getLogger(__name__) @@ -23,7 +22,7 @@ def _get_thermostat(api: nuheat.NuHeat, serial_number: str) -> nuheat.NuHeatTher return api.get_thermostat(serial_number) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NuHeatConfigEntry) -> bool: """Set up NuHeat from a config entry.""" conf = entry.data @@ -52,20 +51,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Failed to login to nuheat: %s", ex) return False - coordinator = NuHeatCoordinator(hass, entry, thermostat) - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = (thermostat, coordinator) + entry.runtime_data = NuHeatCoordinator(hass, entry, thermostat) 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: NuHeatConfigEntry) -> 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/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index e666e4be0cd..4625614e773 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -18,7 +18,6 @@ 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.helpers import event as event_helper @@ -27,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, NUHEAT_API_STATE_SHIFT_DELAY -from .coordinator import NuHeatCoordinator +from .coordinator import NuHeatConfigEntry, NuHeatCoordinator _LOGGER = logging.getLogger(__name__) @@ -55,14 +54,15 @@ SCHEDULE_MODE_TO_PRESET_MODE_MAP = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NuHeatConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the NuHeat thermostat(s).""" - thermostat, coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data temperature_unit = hass.config.units.temperature_unit - entity = NuHeatThermostat(coordinator, thermostat, temperature_unit) + + entity = NuHeatThermostat(coordinator, coordinator.thermostat, temperature_unit) # No longer need a service as set_hvac_mode to auto does this # since climate 1.0 has been implemented diff --git a/homeassistant/components/nuheat/coordinator.py b/homeassistant/components/nuheat/coordinator.py index 6555f7376ed..e1c61bbf1cc 100644 --- a/homeassistant/components/nuheat/coordinator.py +++ b/homeassistant/components/nuheat/coordinator.py @@ -16,15 +16,18 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(minutes=5) +type NuHeatConfigEntry = ConfigEntry[NuHeatCoordinator] + + class NuHeatCoordinator(DataUpdateCoordinator[None]): """Coordinator for NuHeat thermostat data.""" - config_entry: ConfigEntry + config_entry: NuHeatConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: NuHeatConfigEntry, thermostat: nuheat.NuHeatThermostat, ) -> None: """Initialize the coordinator.""" diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 6e89fd074b9..8836ea0d0af 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -1,9 +1,6 @@ """The nuki component.""" -from __future__ import annotations - import asyncio -from dataclasses import dataclass from http import HTTPStatus import logging @@ -14,7 +11,6 @@ from requests.exceptions import RequestException from homeassistant import exceptions from homeassistant.components import webhook -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, @@ -28,7 +24,7 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.update_coordinator import UpdateFailed from .const import CONF_ENCRYPT_TOKEN, DEFAULT_TIMEOUT, DOMAIN -from .coordinator import NukiCoordinator +from .coordinator import NukiConfigEntry, NukiCoordinator, NukiEntryData from .helpers import NukiWebhookException, parse_id _LOGGER = logging.getLogger(__name__) @@ -36,22 +32,12 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] -@dataclass(slots=True) -class NukiEntryData: - """Class to hold Nuki data.""" - - coordinator: NukiCoordinator - bridge: NukiBridge - locks: list[NukiLock] - openers: list[NukiOpener] - - def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOpener]]: return bridge.locks, bridge.openers async def _create_webhook( - hass: HomeAssistant, entry: ConfigEntry, bridge: NukiBridge + hass: HomeAssistant, entry: NukiConfigEntry, bridge: NukiBridge ) -> None: # Create HomeAssistant webhook async def handle_webhook( @@ -63,16 +49,14 @@ async def _create_webhook( except ValueError: return web.Response(status=HTTPStatus.BAD_REQUEST) - entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] - locks = entry_data.locks - openers = entry_data.openers + locks = entry.runtime_data.locks + openers = entry.runtime_data.openers devices = [x for x in locks + openers if x.nuki_id == data["nukiId"]] if len(devices) == 1: devices[0].update_from_callback(data) - coordinator = entry_data.coordinator - coordinator.async_set_updated_data(None) + entry.runtime_data.coordinator.async_set_updated_data(None) return web.Response(status=HTTPStatus.OK) @@ -157,11 +141,9 @@ def _remove_webhook(bridge: NukiBridge, entry_id: str) -> None: bridge.callback_remove(item["id"]) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NukiConfigEntry) -> bool: """Set up the Nuki entry.""" - hass.data.setdefault(DOMAIN, {}) - # Migration of entry unique_id if isinstance(entry.unique_id, int): new_id = parse_id(entry.unique_id) @@ -225,7 +207,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator = NukiCoordinator(hass, entry, bridge, locks, openers) - hass.data[DOMAIN][entry.entry_id] = NukiEntryData( + entry.runtime_data = NukiEntryData( coordinator=coordinator, bridge=bridge, locks=locks, @@ -240,16 +222,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: NukiConfigEntry) -> bool: """Unload the Nuki entry.""" webhook.async_unregister(hass, entry.entry_id) - entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] try: async with asyncio.timeout(10): await hass.async_add_executor_job( _remove_webhook, - entry_data.bridge, + entry.runtime_data.bridge, entry.entry_id, ) except InvalidCredentialsException as err: @@ -261,8 +242,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unable to remove callback. Error communicating with Bridge: {err}" ) from err - 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/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 7ba908c13e4..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 @@ -9,23 +7,21 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -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 NukiEntryData -from .const import DOMAIN +from .coordinator import NukiConfigEntry from .entity import NukiEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NukiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki binary sensors.""" - entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] + entry_data = entry.runtime_data entities: list[NukiEntity] = [] diff --git a/homeassistant/components/nuki/coordinator.py b/homeassistant/components/nuki/coordinator.py index cccff99e397..ed162a54631 100644 --- a/homeassistant/components/nuki/coordinator.py +++ b/homeassistant/components/nuki/coordinator.py @@ -1,9 +1,8 @@ """Coordinator for the nuki component.""" -from __future__ import annotations - import asyncio from collections import defaultdict +from dataclasses import dataclass from datetime import timedelta import logging @@ -25,16 +24,28 @@ _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = timedelta(seconds=30) +type NukiConfigEntry = ConfigEntry[NukiEntryData] + + +@dataclass(slots=True) +class NukiEntryData: + """Class to hold Nuki data.""" + + coordinator: NukiCoordinator + bridge: NukiBridge + locks: list[NukiLock] + openers: list[NukiOpener] + class NukiCoordinator(DataUpdateCoordinator[None]): """Data Update Coordinator for the Nuki integration.""" - config_entry: ConfigEntry + config_entry: NukiConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NukiConfigEntry, bridge: NukiBridge, locks: list[NukiLock], openers: list[NukiOpener], 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 95c01eac730..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 @@ -12,24 +10,23 @@ from requests.exceptions import RequestException import voluptuous as vol from homeassistant.components.lock import LockEntity, LockEntityFeature -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 . import NukiEntryData -from .const import ATTR_ENABLE, ATTR_UNLATCH, DOMAIN, ERROR_STATES +from .const import ATTR_ENABLE, ATTR_UNLATCH, ERROR_STATES +from .coordinator import NukiConfigEntry from .entity import NukiEntity from .helpers import CannotConnect async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NukiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki lock platform.""" - entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] + entry_data = entry.runtime_data coordinator = entry_data.coordinator entities: list[NukiDeviceEntity] = [ diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index 46bb165543d..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 ( @@ -9,23 +7,21 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -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 . import NukiEntryData -from .const import DOMAIN +from .coordinator import NukiConfigEntry from .entity import NukiEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NukiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Nuki lock sensor.""" - entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] + entry_data = entry.runtime_data async_add_entities( NukiBatterySensor(entry_data.coordinator, lock) for lock in entry_data.locks diff --git a/homeassistant/components/numato/binary_sensor.py b/homeassistant/components/numato/binary_sensor.py index c1c251e0074..7216c9711a8 100644 --- a/homeassistant/components/numato/binary_sensor.py +++ b/homeassistant/components/numato/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor platform integration for Numato USB GPIO expanders.""" -from __future__ import annotations - from functools import partial import logging diff --git a/homeassistant/components/numato/sensor.py b/homeassistant/components/numato/sensor.py index 99ef69baa7b..f80e6823ab7 100644 --- a/homeassistant/components/numato/sensor.py +++ b/homeassistant/components/numato/sensor.py @@ -1,7 +1,5 @@ """Sensor platform integration for ADC ports of Numato USB GPIO expanders.""" -from __future__ import annotations - import logging from numato_gpio import NumatoGpioError diff --git a/homeassistant/components/numato/switch.py b/homeassistant/components/numato/switch.py index 0a7522c8b11..997f3956bee 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 diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index b30c9425b0a..7b6eb3477d6 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 diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 78ee067bc55..ab367ac24ab 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, @@ -168,7 +167,7 @@ class NumberDeviceClass(StrEnum): CURRENT = "current" """Current. - Unit of measurement: `A`, `mA` + Unit of measurement: `A`, `mA`, `μA` """ DATA_RATE = "data_rate" @@ -224,7 +223,7 @@ class NumberDeviceClass(StrEnum): FREQUENCY = "frequency" """Frequency. - Unit of measurement: `Hz`, `kHz`, `MHz`, `GHz` + Unit of measurement: `mHz`, `Hz`, `kHz`, `MHz`, `GHz` """ GAS = "gas" @@ -629,6 +628,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..a91cc48ef24 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 ( 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..1b8a7ca2ee3 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 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/__init__.py b/homeassistant/components/nzbget/__init__.py index 5060e6ad024..d24aaeb8620 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -1,13 +1,12 @@ """The NZBGet integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DATA_COORDINATOR, DATA_UNDO_UPDATE_LISTENER, DOMAIN -from .coordinator import NZBGetDataUpdateCoordinator +from .const import DOMAIN +from .coordinator import NZBGetConfigEntry, NZBGetDataUpdateCoordinator from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -22,37 +21,26 @@ 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: NZBGetConfigEntry) -> bool: """Set up NZBGet from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - coordinator = NZBGetDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - undo_listener = entry.add_update_listener(_async_update_listener) + entry.runtime_data = coordinator - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_UNDO_UPDATE_LISTENER: undo_listener, - } + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) 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: NZBGetConfigEntry) -> 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][DATA_UNDO_UPDATE_LISTENER]() - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: NZBGetConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/nzbget/config_flow.py b/homeassistant/components/nzbget/config_flow.py index a99d3d3f328..4d6179506a5 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 @@ -30,12 +28,12 @@ def _validate_input(data: dict[str, Any]) -> None: Data has the keys from DATA_SCHEMA with values provided by the user. """ nzbget_api = NZBGetAPI( - data[CONF_HOST], - data.get(CONF_USERNAME), - data.get(CONF_PASSWORD), - data[CONF_SSL], - data[CONF_VERIFY_SSL], - data[CONF_PORT], + host=data[CONF_HOST], + username=data.get(CONF_USERNAME), + password=data.get(CONF_PASSWORD), + secure=data[CONF_SSL], + verify_certificate=data[CONF_VERIFY_SSL], + port=data[CONF_PORT], ) nzbget_api.version() diff --git a/homeassistant/components/nzbget/const.py b/homeassistant/components/nzbget/const.py index 6742567bbf2..cc704e9ae86 100644 --- a/homeassistant/components/nzbget/const.py +++ b/homeassistant/components/nzbget/const.py @@ -5,10 +5,6 @@ DOMAIN = "nzbget" # Attributes ATTR_SPEED = "speed" -# Data -DATA_COORDINATOR = "coordinator" -DATA_UNDO_UPDATE_LISTENER = "undo_update_listener" - # Defaults DEFAULT_NAME = "NZBGet" DEFAULT_PORT = 6789 diff --git a/homeassistant/components/nzbget/coordinator.py b/homeassistant/components/nzbget/coordinator.py index 9e6b06da760..855ff532e72 100644 --- a/homeassistant/components/nzbget/coordinator.py +++ b/homeassistant/components/nzbget/coordinator.py @@ -23,24 +23,27 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type NZBGetConfigEntry = ConfigEntry[NZBGetDataUpdateCoordinator] + + class NZBGetDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching NZBGet data.""" - config_entry: ConfigEntry + config_entry: NZBGetConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NZBGetConfigEntry, ) -> None: """Initialize global NZBGet data updater.""" self.nzbget = NZBGetAPI( - config_entry.data[CONF_HOST], - config_entry.data.get(CONF_USERNAME), - config_entry.data.get(CONF_PASSWORD), - config_entry.data[CONF_SSL], - config_entry.data[CONF_VERIFY_SSL], - config_entry.data[CONF_PORT], + host=config_entry.data[CONF_HOST], + username=config_entry.data.get(CONF_USERNAME), + password=config_entry.data.get(CONF_PASSWORD), + secure=config_entry.data[CONF_SSL], + verify_certificate=config_entry.data[CONF_VERIFY_SSL], + port=config_entry.data[CONF_PORT], ) self._completed_downloads_init = False diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 2328bf453f0..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 @@ -10,15 +8,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import NZBGetDataUpdateCoordinator +from .coordinator import NZBGetConfigEntry, NZBGetDataUpdateCoordinator from .entity import NZBGetEntity _LOGGER = logging.getLogger(__name__) @@ -92,13 +88,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NZBGetConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up NZBGet sensor based on a config entry.""" - coordinator: NZBGetDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + coordinator = entry.runtime_data entities = [ NZBGetSensor(coordinator, entry.entry_id, entry.data[CONF_NAME], description) for description in SENSOR_TYPES diff --git a/homeassistant/components/nzbget/services.py b/homeassistant/components/nzbget/services.py index ebcdd362b0c..0b5464c4f01 100644 --- a/homeassistant/components/nzbget/services.py +++ b/homeassistant/components/nzbget/services.py @@ -8,7 +8,6 @@ from homeassistant.helpers import config_validation as cv from .const import ( ATTR_SPEED, - DATA_COORDINATOR, DEFAULT_SPEED_LIMIT, DOMAIN, SERVICE_PAUSE, @@ -30,7 +29,7 @@ def _get_coordinator(call: ServiceCall) -> NZBGetDataUpdateCoordinator: translation_domain=DOMAIN, translation_key="invalid_config_entry", ) - return call.hass.data[DOMAIN][entries[0].entry_id][DATA_COORDINATOR] + return entries[0].runtime_data def pause(call: ServiceCall) -> None: diff --git a/homeassistant/components/nzbget/switch.py b/homeassistant/components/nzbget/switch.py index a4b2dde4c47..0963f9e51c0 100644 --- a/homeassistant/components/nzbget/switch.py +++ b/homeassistant/components/nzbget/switch.py @@ -1,29 +1,23 @@ """Support for NZBGet switches.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_COORDINATOR, DOMAIN -from .coordinator import NZBGetDataUpdateCoordinator +from .coordinator import NZBGetConfigEntry, NZBGetDataUpdateCoordinator from .entity import NZBGetEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NZBGetConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up NZBGet sensor based on a config entry.""" - coordinator: NZBGetDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - DATA_COORDINATOR - ] + """Set up NZBGet switch based on a config entry.""" + coordinator = entry.runtime_data switches = [ NZBGetDownloadSwitch( 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/__init__.py b/homeassistant/components/obihai/__init__.py index 43fd3e3426b..6262661d315 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -6,10 +6,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac from .connectivity import ObihaiConnection -from .const import DOMAIN, LOGGER, PLATFORMS +from .const import LOGGER, PLATFORMS + +type ObihaiConfigEntry = ConfigEntry[ObihaiConnection] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ObihaiConfigEntry) -> bool: """Set up from a config entry.""" requester = ObihaiConnection( @@ -18,20 +20,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: password=entry.data[CONF_PASSWORD], ) await hass.async_add_executor_job(requester.update) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = requester + entry.runtime_data = requester await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: ObihaiConfigEntry) -> bool: """Migrate old entry.""" version = entry.version LOGGER.debug("Migrating from version %s", version) if version != 2: - requester: ObihaiConnection = hass.data[DOMAIN][entry.entry_id] + requester = entry.runtime_data device_mac = await hass.async_add_executor_job( requester.pyobihai.get_device_mac @@ -45,6 +47,6 @@ async def async_migrate_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: ObihaiConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/obihai/button.py b/homeassistant/components/obihai/button.py index 9cef92d3fce..c9c4419daf5 100644 --- a/homeassistant/components/obihai/button.py +++ b/homeassistant/components/obihai/button.py @@ -1,20 +1,18 @@ """Obihai button module.""" -from __future__ import annotations - from homeassistant.components.button import ( ButtonDeviceClass, 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 import entity_platform +from . import ObihaiConfigEntry from .connectivity import ObihaiConnection -from .const import DOMAIN, OBIHAI +from .const import OBIHAI BUTTON_DESCRIPTION = ButtonEntityDescription( key="reboot", @@ -26,12 +24,12 @@ BUTTON_DESCRIPTION = ButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ObihaiConfigEntry, async_add_entities: entity_platform.AddConfigEntryEntitiesCallback, ) -> None: - """Set up the Obihai sensor entries.""" + """Set up the Obihai button entries.""" - requester: ObihaiConnection = hass.data[DOMAIN][entry.entry_id] + requester = entry.runtime_data buttons = [ObihaiButton(requester)] async_add_entities(buttons, update_before_add=True) diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index 03f6348ebac..dd98bfe8c72 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 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 ec29238201a..c3113723b38 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -1,30 +1,28 @@ """Support for Obihai Sensors.""" -from __future__ import annotations - import datetime from requests.exceptions import RequestException from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import ObihaiConfigEntry from .connectivity import ObihaiConnection -from .const import DOMAIN, LOGGER, OBIHAI +from .const import LOGGER, OBIHAI SCAN_INTERVAL = datetime.timedelta(seconds=5) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ObihaiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Obihai sensor entries.""" - requester: ObihaiConnection = hass.data[DOMAIN][entry.entry_id] + requester = entry.runtime_data sensors = [ObihaiServiceSensors(requester, key) for key in requester.services] 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 b93743b2bb8..bd33a97b9eb 100644 --- a/homeassistant/components/occupancy/strings.json +++ b/homeassistant/components/occupancy/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted occupancy sensors.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted occupancy sensors to trigger on.", - "trigger_behavior_name": "Behavior" + "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_detected": { "description": "Tests if one or more occupancy sensors are detecting occupancy.", "fields": { "behavior": { - "description": "[%key:component::occupancy::common::condition_behavior_description%]", "name": "[%key:component::occupancy::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::occupancy::common::condition_for_name%]" } }, "name": "Occupancy is detected" @@ -20,36 +22,25 @@ "description": "Tests if one or more occupancy sensors are not detecting occupancy.", "fields": { "behavior": { - "description": "[%key:component::occupancy::common::condition_behavior_description%]", "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": { "description": "Triggers after one or more occupancy sensors stop detecting occupancy.", "fields": { "behavior": { - "description": "[%key:component::occupancy::common::trigger_behavior_description%]", "name": "[%key:component::occupancy::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::occupancy::common::trigger_for_name%]" } }, "name": "Occupancy cleared" @@ -58,8 +49,10 @@ "description": "Triggers after one or more occupancy sensors start detecting occupancy.", "fields": { "behavior": { - "description": "[%key:component::occupancy::common::trigger_behavior_description%]", "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..ee355e2d4d9 100644 --- a/homeassistant/components/occupancy/triggers.yaml +++ b/homeassistant/components/occupancy/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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 a582832d477..94677b9d3e1 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 @@ -9,7 +7,7 @@ import aiohttp from pyoctoprintapi import OctoprintClient import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntryState from homeassistant.const import ( CONF_API_KEY, CONF_BINARY_SENSORS, @@ -34,7 +32,7 @@ from homeassistant.util import slugify as util_slugify from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .const import CONF_BAUDRATE, DOMAIN, SERVICE_CONNECT -from .coordinator import OctoprintDataUpdateCoordinator +from .coordinator import OctoprintConfigEntry, OctoprintDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -168,12 +166,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: OctoprintConfigEntry) -> bool: """Set up OctoPrint from a config entry.""" - - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - if CONF_VERIFY_SSL not in entry.data: data = {**entry.data, CONF_VERIFY_SSL: True} hass.config_entries.async_update_entry(entry, data=data) @@ -210,10 +204,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": coordinator, - "client": client, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -237,14 +228,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: OctoprintConfigEntry) -> 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 async_get_client_for_service_call( @@ -256,8 +242,9 @@ 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 data := hass.data[DOMAIN].get(entry_id): - return cast(OctoprintClient, data["client"]) + if entry := hass.config_entries.async_get_entry(entry_id): + if entry.domain == DOMAIN and entry.state == ConfigEntryState.LOADED: + return cast(OctoprintConfigEntry, entry).runtime_data.octoprint raise ServiceValidationError( translation_domain=DOMAIN, diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py index 4d12ef15a4e..89da70cf889 100644 --- a/homeassistant/components/octoprint/binary_sensor.py +++ b/homeassistant/components/octoprint/binary_sensor.py @@ -1,30 +1,24 @@ """Support for monitoring OctoPrint binary sensors.""" -from __future__ import annotations - from abc import abstractmethod from pyoctoprintapi import OctoprintPrinterInfo 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 homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import OctoprintDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import OctoprintConfigEntry, OctoprintDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OctoprintConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available OctoPrint binary sensors.""" - coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ]["coordinator"] + coordinator = config_entry.runtime_data device_id = config_entry.unique_id assert device_id is not None diff --git a/homeassistant/components/octoprint/button.py b/homeassistant/components/octoprint/button.py index 3a128fcd7aa..fe167702745 100644 --- a/homeassistant/components/octoprint/button.py +++ b/homeassistant/components/octoprint/button.py @@ -3,26 +3,22 @@ from pyoctoprintapi import OctoprintClient, OctoprintPrinterInfo from homeassistant.components.button import ButtonDeviceClass, 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 homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import OctoprintDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import OctoprintConfigEntry, OctoprintDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OctoprintConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Octoprint control buttons.""" - coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ]["coordinator"] - client: OctoprintClient = hass.data[DOMAIN][config_entry.entry_id]["client"] + coordinator = config_entry.runtime_data + client = coordinator.octoprint device_id = config_entry.unique_id assert device_id is not None diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py index 37347539d5b..c3b528d9bfc 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -1,30 +1,24 @@ """Support for OctoPrint binary camera.""" -from __future__ import annotations - -from pyoctoprintapi import OctoprintClient, WebcamSettings +from pyoctoprintapi import WebcamSettings from homeassistant.components.mjpeg import MjpegCamera -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import OctoprintDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import OctoprintConfigEntry, OctoprintDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OctoprintConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available OctoPrint camera.""" - coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ]["coordinator"] - client: OctoprintClient = hass.data[DOMAIN][config_entry.entry_id]["client"] + coordinator = config_entry.runtime_data + client = coordinator.octoprint device_id = config_entry.unique_id assert device_id is not None 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 bb006329ff1..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 @@ -20,19 +18,21 @@ from homeassistant.util import dt as dt_util from .const import DOMAIN +type OctoprintConfigEntry = ConfigEntry[OctoprintDataUpdateCoordinator] + _LOGGER = logging.getLogger(__name__) class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching Octoprint data.""" - config_entry: ConfigEntry + config_entry: OctoprintConfigEntry def __init__( self, hass: HomeAssistant, octoprint: OctoprintClient, - config_entry: ConfigEntry, + config_entry: OctoprintConfigEntry, interval: int, ) -> None: """Initialize.""" @@ -43,7 +43,7 @@ class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): name=f"octoprint-{config_entry.entry_id}", update_interval=timedelta(seconds=interval), ) - self._octoprint = octoprint + self.octoprint = octoprint self._printer_offline = False self.data = {"printer": None, "job": None, "last_read_time": None} @@ -51,7 +51,7 @@ class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): """Update data via API.""" printer = None try: - job = await self._octoprint.get_job_info() + job = await self.octoprint.get_job_info() except UnauthorizedException as err: raise ConfigEntryAuthFailed from err except ApiError as err: @@ -61,7 +61,7 @@ class OctoprintDataUpdateCoordinator(DataUpdateCoordinator): # printer will return a 409, so continue using the last # reading if there is one try: - printer = await self._octoprint.get_printer_info() + printer = await self.octoprint.get_printer_info() except PrinterOffline: if not self._printer_offline: _LOGGER.debug("Unable to retrieve printer information: Printer offline") diff --git a/homeassistant/components/octoprint/number.py b/homeassistant/components/octoprint/number.py index 93fa32a9e33..3bb51493e86 100644 --- a/homeassistant/components/octoprint/number.py +++ b/homeassistant/components/octoprint/number.py @@ -1,21 +1,18 @@ """Support for OctoPrint number entities.""" -from __future__ import annotations - import logging from pyoctoprintapi import OctoprintClient from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import OctoprintDataUpdateCoordinator from .const import DOMAIN +from .coordinator import OctoprintConfigEntry, OctoprintDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -37,14 +34,12 @@ def is_first_extruder(tool_name: str) -> bool: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OctoprintConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OctoPrint number entities.""" - coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ]["coordinator"] - client: OctoprintClient = hass.data[DOMAIN][config_entry.entry_id]["client"] + coordinator = config_entry.runtime_data + client = coordinator.octoprint device_id = config_entry.unique_id assert device_id is not None diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 26ef8721d51..9162970e515 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 @@ -12,14 +10,12 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfInformation, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import OctoprintDataUpdateCoordinator -from .const import DOMAIN +from .coordinator import OctoprintConfigEntry, OctoprintDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -35,13 +31,11 @@ def _is_printer_printing(printer: OctoprintPrinterInfo) -> bool: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OctoprintConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the available OctoPrint sensors.""" - coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ]["coordinator"] + coordinator = config_entry.runtime_data device_id = config_entry.unique_id assert device_id is not None 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/__init__.py b/homeassistant/components/ohmconnect/__init__.py index 1713f82a59b..3d3c9ca3447 100644 --- a/homeassistant/components/ohmconnect/__init__.py +++ b/homeassistant/components/ohmconnect/__init__.py @@ -1 +1 @@ -"""The ohmconnect component.""" +"""The OhmConnect integration.""" 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..a402376f990 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" } diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 192dede3dbc..236492603f2 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.0"] } 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..551076059ce 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" } diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index f95f8c8881f..30be0f99b15 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 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..6feba0f83ed 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 diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index cba8559e826..e59fc329d94 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -1,7 +1,5 @@ """The conversation platform for the Ollama integration.""" -from __future__ import annotations - from typing import Literal from homeassistant.components import conversation diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index 946f0fea917..1902d0ac75e 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 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..4bd5aef71cb --- /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 needed to trigger at the next 15-minute 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..a9d0e5d43cf --- /dev/null +++ b/homeassistant/components/omie/sensor.py @@ -0,0 +1,102 @@ +"""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, + has_entity_name=True, + 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_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 + + @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..42cb86574e1 --- /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 the values for this series from the market data, keyed by a datetime in 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/__init__.py b/homeassistant/components/omnilogic/__init__.py index 19dffc1a051..89bf1f3b85d 100644 --- a/homeassistant/components/omnilogic/__init__.py +++ b/homeassistant/components/omnilogic/__init__.py @@ -4,27 +4,20 @@ import logging from omnilogic import LoginException, OmniLogic, OmniLogicException -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.helpers import aiohttp_client -from .const import ( - CONF_SCAN_INTERVAL, - COORDINATOR, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - OMNI_API, -) -from .coordinator import OmniLogicUpdateCoordinator +from .const import CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL +from .coordinator import OmniLogicConfigEntry, OmniLogicUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OmniLogicConfigEntry) -> bool: """Set up Omnilogic from a config entry.""" conf = entry.data @@ -56,21 +49,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: coordinator, - OMNI_API: api, - } + 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: OmniLogicConfigEntry) -> 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/omnilogic/config_flow.py b/homeassistant/components/omnilogic/config_flow.py index dfbd010ea98..6e7f43ef3eb 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=hass-config-flow-polling-field vol.Optional( CONF_SCAN_INTERVAL, default=self.config_entry.options.get( diff --git a/homeassistant/components/omnilogic/coordinator.py b/homeassistant/components/omnilogic/coordinator.py index 24c8cdf2554..11bbc6f835d 100644 --- a/homeassistant/components/omnilogic/coordinator.py +++ b/homeassistant/components/omnilogic/coordinator.py @@ -15,17 +15,20 @@ from .const import ALL_ITEM_KINDS _LOGGER = logging.getLogger(__name__) +type OmniLogicConfigEntry = ConfigEntry[OmniLogicUpdateCoordinator] + + class OmniLogicUpdateCoordinator(DataUpdateCoordinator[dict[tuple, dict[str, Any]]]): """Class to manage fetching update data from single endpoint.""" - config_entry: ConfigEntry + config_entry: OmniLogicConfigEntry def __init__( self, hass: HomeAssistant, api: OmniLogic, name: str, - config_entry: ConfigEntry, + config_entry: OmniLogicConfigEntry, polling_interval: int, ) -> None: """Initialize the global Omnilogic data updater.""" diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index 522dcc4f3cd..2778abba464 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -3,7 +3,6 @@ from typing import Any from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -16,21 +15,19 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import check_guard -from .const import COORDINATOR, DEFAULT_PH_OFFSET, DOMAIN, PUMP_TYPES -from .coordinator import OmniLogicUpdateCoordinator +from .const import DEFAULT_PH_OFFSET, PUMP_TYPES +from .coordinator import OmniLogicConfigEntry, OmniLogicUpdateCoordinator from .entity import OmniLogicEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OmniLogicConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator: OmniLogicUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ] + coordinator = entry.runtime_data entities = [] for item_id, item in coordinator.data.items(): diff --git a/homeassistant/components/omnilogic/switch.py b/homeassistant/components/omnilogic/switch.py index 9583194f41b..a1a7847eaf2 100644 --- a/homeassistant/components/omnilogic/switch.py +++ b/homeassistant/components/omnilogic/switch.py @@ -7,14 +7,13 @@ from omnilogic import OmniLogicException 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 .common import check_guard -from .const import COORDINATOR, DOMAIN, PUMP_TYPES -from .coordinator import OmniLogicUpdateCoordinator +from .const import PUMP_TYPES +from .coordinator import OmniLogicConfigEntry, OmniLogicUpdateCoordinator from .entity import OmniLogicEntity SERVICE_SET_SPEED = "set_pump_speed" @@ -23,14 +22,12 @@ OMNILOGIC_SWITCH_OFF = 7 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OmniLogicConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the light platform.""" + """Set up the switch platform.""" - coordinator: OmniLogicUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ] + coordinator = entry.runtime_data entities = [] for item_id, item in coordinator.data.items(): 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/__init__.py b/homeassistant/components/ondilo_ico/__init__.py index 28bb6719c7f..12a856d7360 100644 --- a/homeassistant/components/ondilo_ico/__init__.py +++ b/homeassistant/components/ondilo_ico/__init__.py @@ -4,7 +4,6 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -17,7 +16,7 @@ from homeassistant.helpers.typing import ConfigType from .api import OndiloClient from .const import DOMAIN, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET -from .coordinator import OndiloIcoPoolsCoordinator +from .coordinator import OndiloIcoConfigEntry, OndiloIcoPoolsCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.SENSOR] @@ -35,7 +34,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: OndiloIcoConfigEntry) -> bool: """Set up Ondilo ICO from a config entry.""" try: implementation = await async_get_config_entry_implementation(hass, entry) @@ -51,17 +50,13 @@ 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: OndiloIcoConfigEntry) -> 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/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py index 7545f6d61e0..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 @@ -16,8 +14,8 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from . import DOMAIN from .api import OndiloClient +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -41,13 +39,16 @@ class OndiloIcoMeasurementData: sensors: dict[str, Any] +type OndiloIcoConfigEntry = ConfigEntry[OndiloIcoPoolsCoordinator] + + class OndiloIcoPoolsCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoPoolData]]): """Fetch Ondilo ICO pools data from API.""" - config_entry: ConfigEntry + config_entry: OndiloIcoConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: OndiloClient + self, hass: HomeAssistant, config_entry: OndiloIcoConfigEntry, api: OndiloClient ) -> None: """Initialize.""" super().__init__( diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 42e65bd0db2..c076e9c2bed 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -1,14 +1,11 @@ """Platform for sensor integration.""" -from __future__ import annotations - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -24,6 +21,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import ( + OndiloIcoConfigEntry, OndiloIcoMeasuresCoordinator, OndiloIcoPoolData, OndiloIcoPoolsCoordinator, @@ -78,11 +76,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OndiloIcoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Ondilo ICO sensors.""" - pools_coordinator: OndiloIcoPoolsCoordinator = hass.data[DOMAIN][entry.entry_id] + pools_coordinator = entry.runtime_data known_entities: set[str] = set() async_add_entities(get_new_entities(pools_coordinator, known_entities)) 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..57bdcf882ec 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 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/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..5ff18b5ac29 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 diff --git a/homeassistant/components/onedrive_for_business/config_flow.py b/homeassistant/components/onedrive_for_business/config_flow.py index c9b3c047317..c673b8c5cea 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 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..96516a04a52 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 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/coordinator.py b/homeassistant/components/onkyo/coordinator.py index d418b09ad04..4031c258feb 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 @@ -122,7 +120,7 @@ class ChannelMutingCoordinator(DataUpdateCoordinator[ChannelMutingData]): """Send muting command for a channel.""" self._desired[channel] = param message_data: ChannelMutingDesired = self.data | self._desired - message = command.ChannelMuting(**message_data) # type: ignore[misc] + message = command.ChannelMuting(**message_data) await self.manager.write(message) async def _update_callback(self, message: Status) -> None: diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index e69c9ef0543..f8f4a496a00 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 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..bcff7c99042 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 diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 39ffb97e09d..1749a0abe4f 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -12,7 +12,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,18 +27,14 @@ 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) @@ -96,7 +91,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 +122,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 +144,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 +153,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 +166,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) 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..2351d95206e 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] ) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index f645444f9c6..ee863f548ff 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 diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 7bcdd33809b..91b615848d3 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) ) 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..d6097bf8aab 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 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/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 9850f72f71d..d614ecfb193 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 @@ -10,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers.httpx_client import get_async_client -from .const import LOGGER +from .const import CONF_WEB_SEARCH, LOGGER PLATFORMS = [Platform.AI_TASK, Platform.CONVERSATION] @@ -56,3 +54,32 @@ async def _async_update_listener( async def async_unload_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool: """Unload OpenRouter.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry( + hass: HomeAssistant, entry: OpenRouterConfigEntry +) -> bool: + """Migrate config entry.""" + LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version) + + if entry.version > 1 or (entry.version == 1 and entry.minor_version > 2): + return False + + if entry.version == 1 and entry.minor_version < 2: + for subentry in entry.subentries.values(): + if CONF_WEB_SEARCH in subentry.data: + continue + + updated_data = {**subentry.data, CONF_WEB_SEARCH: False} + + hass.config_entries.async_update_subentry( + entry, subentry, data=updated_data + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + LOGGER.info( + "Migration to version %s.%s successful", entry.version, entry.minor_version + ) + + return True 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 db9af4c0f26..f1a2a155bc2 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 @@ -27,6 +25,7 @@ from homeassistant.core import callback from homeassistant.helpers import llm from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( + BooleanSelector, SelectOptionDict, SelectSelector, SelectSelectorConfig, @@ -34,7 +33,12 @@ from homeassistant.helpers.selector import ( TemplateSelector, ) -from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS +from .const import ( + CONF_PROMPT, + CONF_WEB_SEARCH, + DOMAIN, + RECOMMENDED_CONVERSATION_OPTIONS, +) _LOGGER = logging.getLogger(__name__) @@ -43,6 +47,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenRouter.""" VERSION = 1 + MINOR_VERSION = 2 @classmethod @callback @@ -66,7 +71,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_API_KEY], async_get_clientsession(self.hass) ) try: - await client.get_key_data() + key_data = await client.get_key_data() except OpenRouterError: errors["base"] = "cannot_connect" except Exception: @@ -74,7 +79,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: return self.async_create_entry( - title="OpenRouter", + title=key_data.label, data=user_input, ) return self.async_show_form( @@ -106,7 +111,7 @@ class OpenRouterSubentryFlowHandler(ConfigSubentryFlow): class ConversationFlowHandler(OpenRouterSubentryFlowHandler): - """Handle subentry flow.""" + """Handle conversation subentry flow.""" def __init__(self) -> None: """Initialize the subentry flow.""" @@ -208,13 +213,20 @@ class ConversationFlowHandler(OpenRouterSubentryFlowHandler): ): SelectSelector( SelectSelectorConfig(options=hass_apis, multiple=True) ), + vol.Optional( + CONF_WEB_SEARCH, + default=self.options.get( + CONF_WEB_SEARCH, + RECOMMENDED_CONVERSATION_OPTIONS[CONF_WEB_SEARCH], + ), + ): BooleanSelector(), } ), ) class AITaskDataFlowHandler(OpenRouterSubentryFlowHandler): - """Handle subentry flow.""" + """Handle AI task subentry flow.""" def __init__(self) -> None: """Initialize the subentry flow.""" diff --git a/homeassistant/components/open_router/const.py b/homeassistant/components/open_router/const.py index 7316d45c3e5..1664f98add2 100644 --- a/homeassistant/components/open_router/const.py +++ b/homeassistant/components/open_router/const.py @@ -9,9 +9,13 @@ DOMAIN = "open_router" LOGGER = logging.getLogger(__package__) CONF_RECOMMENDED = "recommended" +CONF_WEB_SEARCH = "web_search" + +RECOMMENDED_WEB_SEARCH = False RECOMMENDED_CONVERSATION_OPTIONS = { CONF_RECOMMENDED: True, CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, + CONF_WEB_SEARCH: RECOMMENDED_WEB_SEARCH, } diff --git a/homeassistant/components/open_router/entity.py b/homeassistant/components/open_router/entity.py index 0a2f62f9c94..6667514a93a 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 @@ -37,9 +35,8 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.json import json_dumps from . import OpenRouterConfigEntry -from .const import DOMAIN, LOGGER +from .const import CONF_WEB_SEARCH, DOMAIN, LOGGER -# Max number of back and forth with the LLM to generate a response MAX_TOOL_ITERATIONS = 10 @@ -52,7 +49,6 @@ def _adjust_schema(schema: dict[str, Any]) -> None: if "required" not in schema: schema["required"] = [] - # Ensure all properties are required for prop, prop_info in schema["properties"].items(): _adjust_schema(prop_info) if prop not in schema["required"]: @@ -233,14 +229,20 @@ class OpenRouterEntity(Entity): ) -> None: """Generate an answer for the chat log.""" + model = self.model + if self.subentry.data.get(CONF_WEB_SEARCH): + model = f"{model}:online" + + extra_body: dict[str, Any] = {"require_parameters": True} + model_args = { - "model": self.model, + "model": model, "user": chat_log.conversation_id, "extra_headers": { "X-Title": "Home Assistant", "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", }, - "extra_body": {"require_parameters": True}, + "extra_body": extra_body, } tools: list[ChatCompletionFunctionToolParam] | None = None @@ -296,6 +298,10 @@ class OpenRouterEntity(Entity): 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( diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index 1a48eb5b44d..5be81a48a75 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -2,7 +2,7 @@ "domain": "open_router", "name": "OpenRouter", "after_dependencies": ["assist_pipeline", "intent"], - "codeowners": ["@joostlek"], + "codeowners": ["@joostlek", "@ab3lson"], "config_flow": true, "dependencies": ["conversation"], "documentation": "https://www.home-assistant.io/integrations/open_router", diff --git a/homeassistant/components/open_router/quality_scale.yaml b/homeassistant/components/open_router/quality_scale.yaml index 9b71a29dc6b..5ac803dfa50 100644 --- a/homeassistant/components/open_router/quality_scale.yaml +++ b/homeassistant/components/open_router/quality_scale.yaml @@ -45,7 +45,7 @@ rules: comment: the integration only integrates state-less entities parallel-updates: todo reauthentication-flow: todo - test-coverage: todo + test-coverage: done # Gold devices: done @@ -63,8 +63,12 @@ rules: docs-supported-functions: todo docs-troubleshooting: todo docs-use-cases: todo - dynamic-devices: todo - entity-category: todo + dynamic-devices: + status: exempt + comment: devices are created via subentries, not discovered dynamically + entity-category: + status: exempt + comment: conversation and AI task entities do not use entity categories entity-device-class: status: exempt comment: no suitable device class for the conversation entity diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json index ab99c3cec1d..caad20f5d4a 100644 --- a/homeassistant/components/open_router/strings.json +++ b/homeassistant/components/open_router/strings.json @@ -23,19 +23,18 @@ "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 reconfiguring.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "entry_type": "AI task", "initiate_flow": { + "reconfigure": "Reconfigure AI task", "user": "Add AI task" }, "step": { "init": { "data": { - "model": "[%key:component::open_router::config_subentries::conversation::step::init::data::model%]" - }, - "data_description": { - "model": "The model to use for the AI task" + "model": "[%key:common::generic::model%]" }, "description": "Configure the AI task" } @@ -45,22 +44,27 @@ "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "entry_not_loaded": "[%key:component::open_router::config_subentries::ai_task_data::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "entry_type": "Conversation agent", "initiate_flow": { + "reconfigure": "Reconfigure conversation agent", "user": "Add conversation agent" }, "step": { "init": { "data": { "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "model": "Model", - "prompt": "[%key:common::config_flow::data::prompt%]" + "model": "[%key:common::generic::model%]", + "prompt": "[%key:common::config_flow::data::prompt%]", + "web_search": "Enable web search" }, "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." + "prompt": "Instruct how the LLM should respond. This can be a template.", + "web_search": "Allow the model to search the web for answers" }, "description": "Configure the conversation agent" } diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 44fed05e136..d41e85cfbed 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 @@ -46,6 +44,8 @@ from .const import ( 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 +58,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 +210,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"): @@ -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..4b2c8c59680 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 @@ -55,6 +53,7 @@ from .const import ( 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 @@ -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", @@ -641,7 +664,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 {}) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 2acf2aa9791..11fd3b69e14 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -24,6 +24,7 @@ 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 +40,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 +72,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/entity.py b/homeassistant/components/openai_conversation/entity.py index 50a4f6f8f7e..07a50d03271 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 ( @@ -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: 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..7ec8fa5a614 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 diff --git a/homeassistant/components/openai_conversation/tts.py b/homeassistant/components/openai_conversation/tts.py index f3ee614d747..13a310eb03d 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 @@ -166,14 +164,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 +181,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 53f161a6c70..4fcc7c95d0a 100644 --- a/homeassistant/components/opendisplay/__init__.py +++ b/homeassistant/components/opendisplay/__init__.py @@ -1,13 +1,13 @@ """Integration for OpenDisplay BLE e-paper displays.""" -from __future__ import annotations - import asyncio import contextlib from dataclasses import dataclass from typing import TYPE_CHECKING from opendisplay import ( + AuthenticationFailedError, + AuthenticationRequiredError, BLEConnectionError, BLETimeoutError, GlobalConfig, @@ -17,8 +17,9 @@ from opendisplay import ( from homeassistant.components.bluetooth import async_ble_device_from_address from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from homeassistant.helpers.typing import ConfigType @@ -26,16 +27,21 @@ from homeassistant.helpers.typing import ConfigType if TYPE_CHECKING: from opendisplay.models import FirmwareVersion -from .const import DOMAIN +from .const import CONF_ENCRYPTION_KEY, DOMAIN +from .coordinator import OpenDisplayCoordinator from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +_BASE_PLATFORMS: list[Platform] = [] +_FLEX_PLATFORMS = [Platform.EVENT, Platform.SENSOR] + @dataclass class OpenDisplayRuntimeData: """Runtime data for an OpenDisplay config entry.""" + coordinator: OpenDisplayCoordinator firmware: FirmwareVersion device_config: GlobalConfig is_flex: bool @@ -45,6 +51,23 @@ class OpenDisplayRuntimeData: type OpenDisplayConfigEntry = ConfigEntry[OpenDisplayRuntimeData] +def _get_encryption_key(entry: OpenDisplayConfigEntry) -> bytes | None: + """Return the encryption key bytes from entry data, or None.""" + raw = entry.data.get(CONF_ENCRYPTION_KEY) + if raw is None: + return None + if len(raw) != 32: + raise ConfigEntryAuthFailed( + "Stored OpenDisplay encryption key is invalid; reauthentication required" + ) + try: + return bytes.fromhex(raw) + except ValueError as err: + raise ConfigEntryAuthFailed( + "Stored OpenDisplay encryption key is invalid; reauthentication required" + ) from err + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the OpenDisplay integration.""" async_setup_services(hass) @@ -63,12 +86,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) f"Could not find OpenDisplay device with address {address}" ) + encryption_key = _get_encryption_key(entry) + try: async with OpenDisplayDevice( - mac_address=address, ble_device=ble_device + mac_address=address, ble_device=ble_device, encryption_key=encryption_key ) as device: fw = await device.read_firmware_version() is_flex = device.is_flex + except (AuthenticationFailedError, AuthenticationRequiredError) as err: + raise ConfigEntryAuthFailed( + f"Encryption key rejected by OpenDisplay device: {err}" + ) from err except (BLEConnectionError, BLETimeoutError, OpenDisplayError) as err: raise ConfigEntryNotReady( f"Failed to connect to OpenDisplay device: {err}" @@ -77,13 +106,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) if TYPE_CHECKING: assert device_config is not None - entry.runtime_data = OpenDisplayRuntimeData( - firmware=fw, - device_config=device_config, - is_flex=is_flex, - ) + coordinator = OpenDisplayCoordinator(hass, address) - # Will be moved to DeviceInfo object in entity.py once entities are added manufacturer = device_config.manufacturer display = device_config.displays[0] color_scheme_enum = display.color_scheme_enum @@ -97,14 +121,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) if display.screen_diagonal_inches is not None else f"{display.pixel_width}x{display.pixel_height}" ) - dr.async_get(hass).async_get_or_create( config_entry_id=entry.entry_id, connections={(CONNECTION_BLUETOOTH, address)}, manufacturer=manufacturer.manufacturer_name, model=f"{size} {color_scheme}", sw_version=f"{fw['major']}.{fw['minor']}", - hw_version=f"{manufacturer.board_type_name or manufacturer.board_type} rev. {manufacturer.board_revision}" + hw_version=( + f"{manufacturer.board_type_name or manufacturer.board_type}" + f" rev. {manufacturer.board_revision}" + ) if is_flex else None, configuration_url="https://opendisplay.org/firmware/config/" @@ -112,6 +138,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenDisplayConfigEntry) else None, ) + entry.runtime_data = OpenDisplayRuntimeData( + coordinator=coordinator, + firmware=fw, + device_config=device_config, + is_flex=is_flex, + ) + + await hass.config_entries.async_forward_entry_setups( + entry, _FLEX_PLATFORMS if is_flex else _BASE_PLATFORMS + ) + entry.async_on_unload(coordinator.async_start()) + return True @@ -124,4 +162,6 @@ async def async_unload_entry( with contextlib.suppress(asyncio.CancelledError): await task - return True + return await hass.config_entries.async_unload_platforms( + entry, _FLEX_PLATFORMS if entry.runtime_data.is_flex else _BASE_PLATFORMS + ) diff --git a/homeassistant/components/opendisplay/config_flow.py b/homeassistant/components/opendisplay/config_flow.py index 9dc37489eb8..bcd93e47109 100644 --- a/homeassistant/components/opendisplay/config_flow.py +++ b/homeassistant/components/opendisplay/config_flow.py @@ -1,12 +1,13 @@ """Config flow for OpenDisplay integration.""" -from __future__ import annotations - +from collections.abc import Mapping import logging -from typing import Any +from typing import TYPE_CHECKING, Any from opendisplay import ( MANUFACTURER_ID, + AuthenticationFailedError, + AuthenticationRequiredError, BLEConnectionError, OpenDisplayDevice, OpenDisplayError, @@ -21,11 +22,14 @@ from homeassistant.components.bluetooth import ( from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ADDRESS -from .const import DOMAIN +from .const import CONF_ENCRYPTION_KEY, DOMAIN _LOGGER = logging.getLogger(__name__) +_ENCRYPTION_KEY_VALIDATOR = vol.All(str.strip, str.lower, vol.Match(r"^[0-9a-f]{32}$")) + + class OpenDisplayConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenDisplay.""" @@ -34,14 +38,16 @@ class OpenDisplayConfigFlow(ConfigFlow, domain=DOMAIN): self._discovery_info: BluetoothServiceInfoBleak | None = None self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} - async def _async_test_connection(self, address: str) -> None: + async def _async_test_connection( + self, address: str, encryption_key: bytes | None = None + ) -> None: """Connect to the device and verify it responds.""" ble_device = async_ble_device_from_address(self.hass, address, connectable=True) if ble_device is None: raise BLEConnectionError(f"Could not find connectable device for {address}") async with OpenDisplayDevice( - mac_address=address, ble_device=ble_device + mac_address=address, ble_device=ble_device, encryption_key=encryption_key ) as device: await device.read_firmware_version() @@ -56,6 +62,8 @@ class OpenDisplayConfigFlow(ConfigFlow, domain=DOMAIN): try: await self._async_test_connection(discovery_info.address) + except AuthenticationRequiredError: + return await self.async_step_encryption_key() except OpenDisplayError: return self.async_abort(reason="cannot_connect") except Exception: @@ -92,6 +100,11 @@ class OpenDisplayConfigFlow(ConfigFlow, domain=DOMAIN): try: await self._async_test_connection(address) + except AuthenticationRequiredError: + self.context["title_placeholders"] = { + "name": self._discovered_devices[address].name + } + return await self.async_step_encryption_key() except OpenDisplayError: errors["base"] = "cannot_connect" except Exception: @@ -128,3 +141,100 @@ class OpenDisplayConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def _async_try_connection( + self, + address: str, + encryption_key: bytes | None, + errors: dict[str, str], + ) -> bool: + """Test connection, populate errors, and return True on success.""" + try: + await self._async_test_connection(address, encryption_key) + except AuthenticationFailedError, AuthenticationRequiredError: + errors[CONF_ENCRYPTION_KEY] = "invalid_auth" + except OpenDisplayError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + return True + return False + + async def async_step_encryption_key( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the encryption key step.""" + errors: dict[str, str] = {} + name: str = self.context["title_placeholders"]["name"] + + if user_input is not None: + try: + key: str = _ENCRYPTION_KEY_VALIDATOR(user_input[CONF_ENCRYPTION_KEY]) + except vol.Invalid: + errors[CONF_ENCRYPTION_KEY] = "invalid_key_format" + else: + if TYPE_CHECKING: + assert self.unique_id is not None + if await self._async_try_connection( + self.unique_id, bytes.fromhex(key), errors + ): + return self.async_create_entry( + title=name, + data={CONF_ENCRYPTION_KEY: key}, + ) + + return self.async_show_form( + step_id="encryption_key", + data_schema=vol.Schema({vol.Required(CONF_ENCRYPTION_KEY): str}), + description_placeholders={"name": name}, + 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: + """Handle reauth confirmation.""" + reauth_entry = self._get_reauth_entry() + errors: dict[str, str] = {} + + if user_input is not None: + key: str | None = None + if user_input[CONF_ENCRYPTION_KEY].strip(): + try: + key = _ENCRYPTION_KEY_VALIDATOR(user_input[CONF_ENCRYPTION_KEY]) + except vol.Invalid: + errors[CONF_ENCRYPTION_KEY] = "invalid_key_format" + + if not errors: + address = reauth_entry.unique_id + if TYPE_CHECKING: + assert address is not None + if await self._async_try_connection( + address, bytes.fromhex(key) if key is not None else None, errors + ): + new_data = dict(reauth_entry.data) + if key is not None: + new_data[CONF_ENCRYPTION_KEY] = key + else: + new_data.pop(CONF_ENCRYPTION_KEY, None) + return self.async_update_reload_and_abort( + reauth_entry, + data=new_data, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + {vol.Optional(CONF_ENCRYPTION_KEY, default=""): str} + ), + description_placeholders={"name": reauth_entry.title}, + errors=errors, + ) diff --git a/homeassistant/components/opendisplay/const.py b/homeassistant/components/opendisplay/const.py index 0db0b2f08fd..664f7e8d306 100644 --- a/homeassistant/components/opendisplay/const.py +++ b/homeassistant/components/opendisplay/const.py @@ -1,3 +1,4 @@ """Constants for the OpenDisplay integration.""" DOMAIN = "opendisplay" +CONF_ENCRYPTION_KEY = "encryption_key" diff --git a/homeassistant/components/opendisplay/coordinator.py b/homeassistant/components/opendisplay/coordinator.py new file mode 100644 index 00000000000..ab65f0e0a12 --- /dev/null +++ b/homeassistant/components/opendisplay/coordinator.py @@ -0,0 +1,88 @@ +"""Passive BLE coordinator for OpenDisplay devices.""" + +from dataclasses import dataclass, field +import logging + +from opendisplay import MANUFACTURER_ID, AdvertisementTracker, parse_advertisement +from opendisplay.models.advertisement import AdvertisementData, ButtonChangeEvent + +from homeassistant.components.bluetooth import ( + BluetoothChange, + BluetoothScanningMode, + BluetoothServiceInfoBleak, +) +from homeassistant.components.bluetooth.passive_update_coordinator import ( + PassiveBluetoothDataUpdateCoordinator, +) +from homeassistant.core import HomeAssistant, callback + +_LOGGER: logging.Logger = logging.getLogger(__package__) + + +@dataclass +class OpenDisplayUpdate: + """Parsed advertisement data for one OpenDisplay device.""" + + address: str + advertisement: AdvertisementData + button_events: list[ButtonChangeEvent] = field(default_factory=list) + + +class OpenDisplayCoordinator(PassiveBluetoothDataUpdateCoordinator): + """Coordinator for passive BLE advertisement updates from an OpenDisplay device.""" + + def __init__(self, hass: HomeAssistant, address: str) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + address, + BluetoothScanningMode.PASSIVE, + connectable=True, + ) + self.data: OpenDisplayUpdate | None = None + self._tracker: AdvertisementTracker = AdvertisementTracker() + + @callback + def _async_handle_unavailable( + self, service_info: BluetoothServiceInfoBleak + ) -> None: + """Handle the device going unavailable.""" + if self._available: + _LOGGER.info("%s: Device is unavailable", service_info.address) + super()._async_handle_unavailable(service_info) + + @callback + def _async_handle_bluetooth_event( + self, + service_info: BluetoothServiceInfoBleak, + change: BluetoothChange, + ) -> None: + """Handle a Bluetooth advertisement event.""" + if not self._available: + _LOGGER.info("%s: Device is available again", service_info.address) + + if MANUFACTURER_ID not in service_info.manufacturer_data: + super()._async_handle_bluetooth_event(service_info, change) + return + + try: + advertisement = parse_advertisement( + service_info.manufacturer_data[MANUFACTURER_ID] + ) + except ValueError as err: + _LOGGER.debug( + "%s: Failed to parse advertisement data: %s", + service_info.address, + err, + 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 new file mode 100644 index 00000000000..622021871db --- /dev/null +++ b/homeassistant/components/opendisplay/entity.py @@ -0,0 +1,29 @@ +"""Base entity for OpenDisplay devices.""" + +from homeassistant.components.bluetooth.passive_update_coordinator import ( + PassiveBluetoothCoordinatorEntity, +) +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.helpers.entity import EntityDescription + +from .coordinator import OpenDisplayCoordinator + + +class OpenDisplayEntity(PassiveBluetoothCoordinatorEntity[OpenDisplayCoordinator]): + """Base class for all OpenDisplay entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: OpenDisplayCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.address}-{description.key}" + + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, coordinator.address)}, + ) 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 f055d425e1c..60b850eff51 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.5.0"] + "requirements": ["py-opendisplay==5.9.0"] } diff --git a/homeassistant/components/opendisplay/quality_scale.yaml b/homeassistant/components/opendisplay/quality_scale.yaml index 720ec101aac..6a14ae56adc 100644 --- a/homeassistant/components/opendisplay/quality_scale.yaml +++ b/homeassistant/components/opendisplay/quality_scale.yaml @@ -6,9 +6,7 @@ rules: comment: | The `opendisplay` integration is a `local_push` integration that does not perform periodic polling. brands: done - common-modules: - status: exempt - comment: Integration does not currently use entities or a DataUpdateCoordinator. + common-modules: done config-flow-test-coverage: done config-flow: done dependency-transparency: done @@ -16,15 +14,9 @@ rules: docs-high-level-description: done docs-installation-instructions: done docs-removal-instructions: done - entity-event-setup: - status: exempt - comment: Integration does not currently provide any entities. - entity-unique-id: - status: exempt - comment: Integration does not currently provide any entities. - has-entity-name: - status: exempt - comment: Integration does not currently provide any entities. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done runtime-data: done test-before-configure: done test-before-setup: done @@ -37,19 +29,11 @@ rules: status: exempt comment: Integration has no options flow. docs-installation-parameters: done - entity-unavailable: - status: exempt - comment: Integration does not currently provide any entities. + entity-unavailable: done integration-owner: done - log-when-unavailable: - status: exempt - comment: Integration does not currently implement any entities or background polling. - parallel-updates: - status: exempt - comment: Integration does not provide any entities. - reauthentication-flow: - status: exempt - comment: Devices do not require authentication. + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done test-coverage: done # Gold @@ -59,9 +43,7 @@ rules: status: exempt comment: The device's BLE MAC address is both its unique identifier and does not change. discovery: done - docs-data-update: - status: exempt - comment: Integration does not poll or push data to entities. + docs-data-update: todo docs-examples: todo docs-known-limitations: todo docs-supported-devices: todo @@ -71,18 +53,10 @@ rules: dynamic-devices: status: exempt comment: Only one device per config entry. New devices are set up as new entries. - entity-category: - status: exempt - comment: Integration does not provide any entities. - entity-device-class: - status: exempt - comment: Integration does not provide any entities. - entity-disabled-by-default: - status: exempt - comment: Integration does not provide any entities. - entity-translations: - status: exempt - comment: Integration does not provide any entities. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done exception-translations: done icon-translations: done reconfiguration-flow: diff --git a/homeassistant/components/opendisplay/sensor.py b/homeassistant/components/opendisplay/sensor.py new file mode 100644 index 00000000000..c1aa02c8e51 --- /dev/null +++ b/homeassistant/components/opendisplay/sensor.py @@ -0,0 +1,104 @@ +"""Sensor platform for OpenDisplay devices.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from opendisplay import voltage_to_percent +from opendisplay.models.advertisement import AdvertisementData +from opendisplay.models.enums import CapacityEstimator, PowerMode + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricPotential, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OpenDisplayConfigEntry +from .entity import OpenDisplayEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class OpenDisplaySensorEntityDescription(SensorEntityDescription): + """Describes an OpenDisplay sensor entity.""" + + value_fn: Callable[[AdvertisementData], float | int | None] + + +_TEMPERATURE_DESCRIPTION = OpenDisplaySensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda adv: adv.temperature_c, +) + +_BATTERY_POWER_MODES = {PowerMode.BATTERY, PowerMode.SOLAR} + +_BATTERY_VOLTAGE_DESCRIPTION = OpenDisplaySensorEntityDescription( + key="battery_voltage", + translation_key="battery_voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda adv: adv.battery_mv, +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: OpenDisplayConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up OpenDisplay sensor entities.""" + coordinator = entry.runtime_data.coordinator + power_config = entry.runtime_data.device_config.power + descriptions: list[OpenDisplaySensorEntityDescription] = [_TEMPERATURE_DESCRIPTION] + + if power_config.power_mode_enum in _BATTERY_POWER_MODES: + capacity_estimator = power_config.capacity_estimator or CapacityEstimator.LI_ION + descriptions += [ + _BATTERY_VOLTAGE_DESCRIPTION, + OpenDisplaySensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda adv: voltage_to_percent( + adv.battery_mv, capacity_estimator + ), + ), + ] + + async_add_entities( + OpenDisplaySensorEntity(coordinator, description) + for description in descriptions + ) + + +class OpenDisplaySensorEntity(OpenDisplayEntity, SensorEntity): + """A sensor entity for an OpenDisplay device.""" + + entity_description: OpenDisplaySensorEntityDescription + + @property + def native_value(self) -> float | int | None: + """Return the sensor value.""" + if self.coordinator.data is None: + return None + return self.entity_description.value_fn(self.coordinator.data.advertisement) diff --git a/homeassistant/components/opendisplay/services.py b/homeassistant/components/opendisplay/services.py index 98de6f677f9..90ba9396f0d 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 @@ -12,6 +10,8 @@ from typing import TYPE_CHECKING, Any import aiohttp from opendisplay import ( + AuthenticationFailedError, + AuthenticationRequiredError, DitherMode, FitMode, OpenDisplayDevice, @@ -38,7 +38,7 @@ from homeassistant.helpers.selector import MediaSelector, MediaSelectorConfig if TYPE_CHECKING: from . import OpenDisplayConfigEntry -from .const import DOMAIN +from .const import CONF_ENCRYPTION_KEY, DOMAIN ATTR_IMAGE = "image" ATTR_ROTATION = "rotation" @@ -193,10 +193,25 @@ async def _async_upload_image(call: ServiceCall) -> None: else: pil_image = await _async_download_image(call.hass, media.url) + raw_key = entry.data.get(CONF_ENCRYPTION_KEY) + if raw_key is not None and len(raw_key) != 32: + entry.async_start_reauth(call.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="authentication_error" + ) + try: + encryption_key = bytes.fromhex(raw_key) if raw_key is not None else None + except ValueError as err: + entry.async_start_reauth(call.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="authentication_error" + ) from err + async with OpenDisplayDevice( mac_address=address, ble_device=ble_device, config=entry.runtime_data.device_config, + encryption_key=encryption_key, ) as device: await device.upload_image( pil_image, @@ -208,6 +223,11 @@ async def _async_upload_image(call: ServiceCall) -> None: ) except asyncio.CancelledError: return + except (AuthenticationFailedError, AuthenticationRequiredError) as err: + entry.async_start_reauth(call.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="authentication_error" + ) from err except OpenDisplayError as err: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="upload_error" diff --git a/homeassistant/components/opendisplay/strings.json b/homeassistant/components/opendisplay/strings.json index 85f1236a60f..92478b3278e 100644 --- a/homeassistant/components/opendisplay/strings.json +++ b/homeassistant/components/opendisplay/strings.json @@ -5,10 +5,13 @@ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_key_format": "The encryption key must be exactly 32 hexadecimal characters (0-9, a-f).", "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name}", @@ -16,6 +19,26 @@ "bluetooth_confirm": { "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" }, + "encryption_key": { + "data": { + "encryption_key": "Encryption key" + }, + "data_description": { + "encryption_key": "Enter the 32-character hexadecimal AES-128 encryption key for this device." + }, + "description": "{name} requires an encryption key to connect.", + "title": "Encryption required" + }, + "reauth_confirm": { + "data": { + "encryption_key": "[%key:component::opendisplay::config::step::encryption_key::data::encryption_key%]" + }, + "data_description": { + "encryption_key": "[%key:component::opendisplay::config::step::encryption_key::data_description::encryption_key%]" + }, + "description": "Authentication failed for {name}. Enter the correct encryption key, or leave blank if encryption has been disabled on the device.", + "title": "Re-authentication required" + }, "user": { "data": { "address": "[%key:common::config_flow::data::device%]" @@ -27,7 +50,30 @@ } } }, + "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" + } + } + }, "exceptions": { + "authentication_error": { + "message": "Authentication failed. Please update the encryption key." + }, "device_not_found": { "message": "Could not find Bluetooth device with address `{address}`." }, diff --git a/homeassistant/components/openerz/sensor.py b/homeassistant/components/openerz/sensor.py index f41b468b224..d78d0feea22 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 diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py index 1e792d19ba6..5afe1b4a12e 100644 --- a/homeassistant/components/openevse/__init__.py +++ b/homeassistant/components/openevse/__init__.py @@ -1,12 +1,11 @@ """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 .coordinator import OpenEVSEConfigEntry, OpenEVSEDataUpdateCoordinator @@ -27,6 +26,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenEVSEConfigEntry) -> await charger.test_and_get() except TimeoutError as ex: raise ConfigEntryNotReady("Unable to connect to charger") from ex + except AuthenticationError as ex: + raise ConfigEntryAuthFailed("Invalid credentials for charger") from ex coordinator = OpenEVSEDataUpdateCoordinator(hass, entry, charger) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/openevse/config_flow.py b/homeassistant/components/openevse/config_flow.py index 264b306654c..866501d8235 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 @@ -170,3 +171,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/coordinator.py b/homeassistant/components/openevse/coordinator.py index dfbb8cc6781..22d3a9bbeb1 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 @@ -65,3 +65,5 @@ class OpenEVSEDataUpdateCoordinator(DataUpdateCoordinator[None]): raise UpdateFailed( f"Timeout communicating with charger: {error}" ) from error + except AuthenticationError as error: + raise ConfigEntryAuthFailed("Invalid credentials for charger") from error 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..68bc31dbdc3 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%]" diff --git a/homeassistant/components/openexchangerates/__init__.py b/homeassistant/components/openexchangerates/__init__.py index ed704a61fed..02e9b7a6e4c 100644 --- a/homeassistant/components/openexchangerates/__init__.py +++ b/homeassistant/components/openexchangerates/__init__.py @@ -1,32 +1,27 @@ """The Open Exchange Rates integration.""" -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_BASE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import BASE_UPDATE_INTERVAL, DOMAIN, LOGGER -from .coordinator import OpenexchangeratesCoordinator +from .coordinator import OpenexchangeratesConfigEntry, OpenexchangeratesCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: OpenexchangeratesConfigEntry +) -> bool: """Set up Open Exchange Rates from a config entry.""" api_key: str = entry.data[CONF_API_KEY] base: str = entry.data[CONF_BASE] # Create one coordinator per base currency per API key. - existing_coordinators: dict[str, OpenexchangeratesCoordinator] = hass.data.get( - DOMAIN, {} - ) existing_coordinator_for_api_key = { - existing_coordinator - for config_entry_id, existing_coordinator in existing_coordinators.items() - if (config_entry := hass.config_entries.async_get_entry(config_entry_id)) - and config_entry.data[CONF_API_KEY] == api_key + existing_entry.runtime_data + for existing_entry in hass.config_entries.async_loaded_entries(DOMAIN) + if existing_entry.data[CONF_API_KEY] == api_key } # Adjust update interval by coordinators per API key. @@ -48,16 +43,15 @@ 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: OpenexchangeratesConfigEntry +) -> 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/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 6245877ddbd..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 @@ -20,16 +18,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CLIENT_TIMEOUT, DOMAIN, LOGGER +type OpenexchangeratesConfigEntry = ConfigEntry[OpenexchangeratesCoordinator] + class OpenexchangeratesCoordinator(DataUpdateCoordinator[Latest]): """Represent a coordinator for Open Exchange Rates API.""" - config_entry: ConfigEntry + config_entry: OpenexchangeratesConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenexchangeratesConfigEntry, session: ClientSession, api_key: str, base: str, diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py index 756823ff0ec..1847fabed1b 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -1,9 +1,6 @@ """Support for openexchangerates.org exchange rates service.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_QUOTE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -11,19 +8,19 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import OpenexchangeratesCoordinator +from .coordinator import OpenexchangeratesConfigEntry, OpenexchangeratesCoordinator ATTRIBUTION = "Data provided by openexchangerates.org" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenexchangeratesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Open Exchange Rates sensor.""" quote: str = config_entry.data.get(CONF_QUOTE, "EUR") - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( OpenexchangeratesSensor( @@ -43,7 +40,7 @@ class OpenexchangeratesSensor( def __init__( self, - config_entry: ConfigEntry, + config_entry: OpenexchangeratesConfigEntry, coordinator: OpenexchangeratesCoordinator, quote: str, enabled: bool, diff --git a/homeassistant/components/opengarage/__init__.py b/homeassistant/components/opengarage/__init__.py index f1f080b30f8..73b6ca334eb 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -1,21 +1,18 @@ """The OpenGarage integration.""" -from __future__ import annotations - import opengarage -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_DEVICE_KEY, DOMAIN -from .coordinator import OpenGarageDataUpdateCoordinator +from .const import CONF_DEVICE_KEY +from .coordinator import OpenGarageConfigEntry, OpenGarageDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OpenGarageConfigEntry) -> bool: """Set up OpenGarage from a config entry.""" open_garage_connection = opengarage.OpenGarage( f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}", @@ -27,17 +24,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry, open_garage_connection ) await open_garage_data_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = open_garage_data_coordinator + entry.runtime_data = open_garage_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: OpenGarageConfigEntry) -> 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/opengarage/binary_sensor.py b/homeassistant/components/opengarage/binary_sensor.py index 33420ab3fd5..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 @@ -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 .const import DOMAIN -from .coordinator import OpenGarageDataUpdateCoordinator +from .coordinator import OpenGarageConfigEntry, OpenGarageDataUpdateCoordinator from .entity import OpenGarageEntity _LOGGER = logging.getLogger(__name__) @@ -30,13 +26,11 @@ SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OpenGarageConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage binary sensors.""" - open_garage_data_coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + open_garage_data_coordinator = entry.runtime_data async_add_entities( OpenGarageBinarySensor( open_garage_data_coordinator, diff --git a/homeassistant/components/opengarage/button.py b/homeassistant/components/opengarage/button.py index 64a4f2f20e7..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 @@ -13,13 +11,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 .const import DOMAIN -from .coordinator import OpenGarageDataUpdateCoordinator +from .coordinator import OpenGarageConfigEntry, OpenGarageDataUpdateCoordinator from .entity import OpenGarageEntity @@ -42,13 +38,11 @@ BUTTONS: tuple[OpenGarageButtonEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenGarageConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage button entities.""" - coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinator = config_entry.runtime_data async_add_entities( OpenGarageButtonEntity( 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 5d5440d6b1b..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 @@ -18,15 +16,18 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type OpenGarageConfigEntry = ConfigEntry[OpenGarageDataUpdateCoordinator] + + class OpenGarageDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching Opengarage data.""" - config_entry: ConfigEntry + config_entry: OpenGarageConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenGarageConfigEntry, open_garage_connection: opengarage.OpenGarage, ) -> None: """Initialize global Opengarage data updater.""" diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 859e3382772..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 @@ -11,12 +9,10 @@ from homeassistant.components.cover import ( CoverEntityFeature, CoverState, ) -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 OpenGarageDataUpdateCoordinator +from .coordinator import OpenGarageConfigEntry, OpenGarageDataUpdateCoordinator from .entity import OpenGarageEntity _LOGGER = logging.getLogger(__name__) @@ -26,12 +22,12 @@ STATES_MAP = {0: CoverState.CLOSED, 1: CoverState.OPEN} async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OpenGarageConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage covers.""" async_add_entities( - [OpenGarageCover(hass.data[DOMAIN][entry.entry_id], cast(str, entry.unique_id))] + [OpenGarageCover(entry.runtime_data, cast(str, entry.unique_id))] ) 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 14d14dd5d23..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 @@ -11,7 +9,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -22,8 +19,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import OpenGarageDataUpdateCoordinator +from .coordinator import OpenGarageConfigEntry from .entity import OpenGarageEntity _LOGGER = logging.getLogger(__name__) @@ -60,13 +56,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OpenGarageConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OpenGarage sensors.""" - open_garage_data_coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + open_garage_data_coordinator = entry.runtime_data async_add_entities( OpenGarageSensor( open_garage_data_coordinator, 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/__init__.py b/homeassistant/components/openhome/__init__.py index 393f0f4065b..887b7cc71c1 100644 --- a/homeassistant/components/openhome/__init__.py +++ b/homeassistant/components/openhome/__init__.py @@ -18,6 +18,8 @@ from .services import async_setup_services _LOGGER = logging.getLogger(__name__) +type OpenhomeConfigEntry = ConfigEntry[Device] + CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) PLATFORMS = [Platform.MEDIA_PLAYER, Platform.UPDATE] @@ -30,7 +32,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenhomeConfigEntry, ) -> bool: """Set up the configuration config entry.""" _LOGGER.debug("Setting up config entry: %s", config_entry.unique_id) @@ -44,18 +46,15 @@ async def async_setup_entry( _LOGGER.debug("Initialised device: %s", device.uuid()) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = device + config_entry.runtime_data = device 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: OpenhomeConfigEntry +) -> bool: """Cleanup before removing config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - 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/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 746468730ef..4bb055b64c0 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 @@ -19,11 +17,11 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) -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 OpenhomeConfigEntry from .const import DOMAIN SUPPORT_OPENHOME = ( @@ -37,14 +35,14 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenhomeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Openhome config entry.""" _LOGGER.debug("Setting up config entry: %s", config_entry.unique_id) - device = hass.data[DOMAIN][config_entry.entry_id] + device = config_entry.runtime_data entity = OpenhomeDevice(device) 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 cc210866e64..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 @@ -13,12 +11,12 @@ from homeassistant.components.update import ( UpdateEntity, UpdateEntityFeature, ) -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 OpenhomeConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -26,14 +24,14 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OpenhomeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up update entities for Reolink component.""" _LOGGER.debug("Setting up config entry: %s", config_entry.unique_id) - device = hass.data[DOMAIN][config_entry.entry_id] + device = config_entry.runtime_data entity = OpenhomeUpdateEntity(device) 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..8853dda5bf7 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 diff --git a/homeassistant/components/openrgb/coordinator.py b/homeassistant/components/openrgb/coordinator.py index c5189d807ab..e34710e916b 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 diff --git a/homeassistant/components/openrgb/light.py b/homeassistant/components/openrgb/light.py index ce4e47c6fa7..b212f1dad67 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 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/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py index 19d19f19a54..261de4aef9e 100644 --- a/homeassistant/components/opensensemap/air_quality.py +++ b/homeassistant/components/opensensemap/air_quality.py @@ -1,7 +1,5 @@ """Support for openSenseMap Air Quality data.""" -from __future__ import annotations - from datetime import timedelta import logging diff --git a/homeassistant/components/opensky/__init__.py b/homeassistant/components/opensky/__init__.py index c69cade5842..386e37b9b59 100644 --- a/homeassistant/components/opensky/__init__.py +++ b/homeassistant/components/opensky/__init__.py @@ -1,22 +1,19 @@ """The opensky component.""" -from __future__ import annotations - from aiohttp import BasicAuth from python_opensky import OpenSky from python_opensky.exceptions import OpenSkyError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_CONTRIBUTING_USER, DOMAIN, PLATFORMS -from .coordinator import OpenSkyDataUpdateCoordinator +from .const import CONF_CONTRIBUTING_USER, PLATFORMS +from .coordinator import OpenSkyConfigEntry, OpenSkyDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OpenSkyConfigEntry) -> bool: """Set up opensky from a config entry.""" client = OpenSky(session=async_get_clientsession(hass)) @@ -34,7 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = OpenSkyDataUpdateCoordinator(hass, entry, client) 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) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -42,12 +39,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: OpenSkyConfigEntry) -> bool: """Unload opensky config entry.""" - 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: OpenSkyConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py index 5e53a805753..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 @@ -9,12 +7,7 @@ from python_opensky import OpenSky from python_opensky.exceptions import OpenSkyUnauthenticatedError 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, @@ -33,6 +26,7 @@ from .const import ( DEFAULT_NAME, DOMAIN, ) +from .coordinator import OpenSkyConfigEntry class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): @@ -41,7 +35,7 @@ class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: OpenSkyConfigEntry, ) -> OpenSkyOptionsFlowHandler: """Get the options flow for this handler.""" return OpenSkyOptionsFlowHandler() diff --git a/homeassistant/components/opensky/coordinator.py b/homeassistant/components/opensky/coordinator.py index f9aab88c904..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 @@ -30,14 +28,16 @@ from .const import ( LOGGER, ) +type OpenSkyConfigEntry = ConfigEntry[OpenSkyDataUpdateCoordinator] + class OpenSkyDataUpdateCoordinator(DataUpdateCoordinator[int]): """An OpenSky Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: OpenSkyConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, opensky: OpenSky + self, hass: HomeAssistant, config_entry: OpenSkyConfigEntry, opensky: OpenSky ) -> None: """Initialize the OpenSky data coordinator.""" super().__init__( diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 0ab5b49f086..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 @@ -10,17 +8,17 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER -from .coordinator import OpenSkyDataUpdateCoordinator +from .coordinator import OpenSkyConfigEntry, OpenSkyDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OpenSkyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize the entries.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ OpenSkySensor( 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..8a2e986d07b 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 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 6edb42427f3..d24691c66c6 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -1,13 +1,10 @@ """Support for UV data from openuv.io.""" -from __future__ import annotations - import asyncio from typing import Any from pyopenuv import Client -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_BINARY_SENSORS, @@ -27,15 +24,18 @@ from .const import ( DATA_UV, DEFAULT_FROM_WINDOW, DEFAULT_TO_WINDOW, - DOMAIN, LOGGER, ) -from .coordinator import OpenUvCoordinator, OpenUvProtectionWindowCoordinator +from .coordinator import ( + OpenUvConfigEntry, + OpenUvCoordinator, + OpenUvProtectionWindowCoordinator, +) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OpenUvConfigEntry) -> bool: """Set up OpenUV as config entry.""" websession = aiohttp_client.async_get_clientsession(hass) client = Client( @@ -78,24 +78,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ] await asyncio.gather(*init_tasks) - hass.data.setdefault(DOMAIN, {}) - hass.data[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: OpenUvConfigEntry) -> bool: """Unload an OpenUV 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: +async def async_migrate_entry(hass: HomeAssistant, entry: OpenUvConfigEntry) -> bool: """Migrate the config entry upon new versions.""" version = entry.version data = {**entry.data} diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index 8165c66e7dd..30418d8398e 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -4,13 +4,12 @@ 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 homeassistant.util.dt import as_local -from .const import DATA_PROTECTION_WINDOW, DOMAIN, LOGGER, TYPE_PROTECTION_WINDOW -from .coordinator import OpenUvCoordinator +from .const import DATA_PROTECTION_WINDOW, LOGGER, TYPE_PROTECTION_WINDOW +from .coordinator import OpenUvConfigEntry from .entity import OpenUvEntity ATTR_PROTECTION_WINDOW_ENDING_TIME = "end_time" @@ -26,12 +25,11 @@ BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW = BinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OpenUvConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - # Once we've successfully authenticated, we re-enable client request retries: - """Set up an OpenUV sensor based on a config entry.""" - coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] + """Set up OpenUV binary sensors for a config entry.""" + coordinators = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py index 52e369fd6df..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 @@ -10,7 +8,7 @@ from pyopenuv import Client from pyopenuv.errors import OpenUvError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, CONF_ELEVATION, @@ -31,6 +29,7 @@ from .const import ( DEFAULT_TO_WINDOW, DOMAIN, ) +from .coordinator import OpenUvConfigEntry STEP_REAUTH_SCHEMA = vol.Schema( { @@ -133,7 +132,9 @@ class OpenUvFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> SchemaOptionsFlowHandler: + def async_get_options_flow( + config_entry: OpenUvConfigEntry, + ) -> SchemaOptionsFlowHandler: """Define the config flow to handle options.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/openuv/coordinator.py b/homeassistant/components/openuv/coordinator.py index b29d272b0ec..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 @@ -20,18 +18,20 @@ from .const import LOGGER DEFAULT_DEBOUNCER_COOLDOWN_SECONDS = 15 * 60 +type OpenUvConfigEntry = ConfigEntry[dict[str, OpenUvCoordinator]] + class OpenUvCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Define an OpenUV data coordinator.""" - config_entry: ConfigEntry + config_entry: OpenUvConfigEntry update_method: Callable[[], Awaitable[dict[str, Any]]] def __init__( self, hass: HomeAssistant, *, - entry: ConfigEntry, + entry: OpenUvConfigEntry, name: str, latitude: str, longitude: str, diff --git a/homeassistant/components/openuv/diagnostics.py b/homeassistant/components/openuv/diagnostics.py index e16316d4148..c95dafe4197 100644 --- a/homeassistant/components/openuv/diagnostics.py +++ b/homeassistant/components/openuv/diagnostics.py @@ -1,11 +1,8 @@ """Diagnostics support for OpenUV.""" -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_API_KEY, CONF_LATITUDE, @@ -14,8 +11,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import OpenUvCoordinator +from .coordinator import OpenUvConfigEntry CONF_COORDINATES = "coordinates" CONF_TITLE = "title" @@ -31,10 +27,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: OpenUvConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] + coordinators = entry.runtime_data return 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 5b681655e2b..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 @@ -12,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UV_INDEX, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -20,7 +17,6 @@ from homeassistant.util.dt import as_local, parse_datetime from .const import ( DATA_UV, - DOMAIN, TYPE_CURRENT_OZONE_LEVEL, TYPE_CURRENT_UV_INDEX, TYPE_CURRENT_UV_LEVEL, @@ -32,7 +28,7 @@ from .const import ( TYPE_SAFE_EXPOSURE_TIME_5, TYPE_SAFE_EXPOSURE_TIME_6, ) -from .coordinator import OpenUvCoordinator +from .coordinator import OpenUvConfigEntry from .entity import OpenUvEntity ATTR_MAX_UV_TIME = "time" @@ -167,11 +163,11 @@ SENSOR_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OpenUvConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a OpenUV sensor based on a config entry.""" - coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id] + coordinators = entry.runtime_data async_add_entities( [ 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..9cd3ba075b8 100644 --- a/homeassistant/components/openweathermap/repairs.py +++ b/homeassistant/components/openweathermap/repairs.py @@ -1,7 +1,5 @@ """Issues for OpenWeatherMap.""" -from __future__ import annotations - from typing import TYPE_CHECKING, cast from homeassistant import data_entry_flow 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 bc085dbfa4d..822851aca74 100644 --- a/homeassistant/components/opnsense/__init__.py +++ b/homeassistant/components/opnsense/__init__.py @@ -2,13 +2,23 @@ import logging -from pyopnsense import diagnostics -from pyopnsense.exceptions import APIException +from aiopnsense import ( + OPNsenseBelowMinFirmware, + OPNsenseClient, + OPNsenseConnectionError, + OPNsenseInvalidAuth, + OPNsenseInvalidURL, + OPNsensePrivilegeMissing, + OPNsenseSSLError, + OPNsenseTimeoutError, + OPNsenseUnknownFirmware, +) import voluptuous as vol 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.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType @@ -40,7 +50,7 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the opnsense component.""" conf = config[DOMAIN] @@ -50,30 +60,73 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: verify_ssl = conf[CONF_VERIFY_SSL] tracker_interfaces = conf[CONF_TRACKER_INTERFACES] - interfaces_client = diagnostics.InterfaceClient( - api_key, api_secret, url, verify_ssl, timeout=20 + session = async_get_clientsession(hass, verify_ssl=verify_ssl) + client = OPNsenseClient( + url, + api_key, + api_secret, + session, + opts={"verify_ssl": verify_ssl}, ) try: - interfaces_client.get_arp() - except APIException: - _LOGGER.exception("Failure while connecting to OPNsense API endpoint") + 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 if tracker_interfaces: # Verify that specified tracker interfaces are valid - netinsight_client = diagnostics.NetworkInsightClient( - api_key, api_secret, url, verify_ssl, timeout=20 - ) - interfaces = list(netinsight_client.get_interfaces().values()) - for interface in tracker_interfaces: - if interface not in interfaces: + known_interfaces = [ + ifinfo.get("name", "") for ifinfo in interfaces_resp.values() + ] + for intf_description in tracker_interfaces: + if intf_description not in known_interfaces: _LOGGER.error( - "Specified OPNsense tracker interface %s is not found", interface + "Specified OPNsense tracker interface %s is not found", + intf_description, ) return False hass.data[OPNSENSE_DATA] = { - CONF_INTERFACE_CLIENT: interfaces_client, + CONF_INTERFACE_CLIENT: client, CONF_TRACKER_INTERFACES: tracker_interfaces, } diff --git a/homeassistant/components/opnsense/device_tracker.py b/homeassistant/components/opnsense/device_tracker.py index 5f6d8d2d436..259a6394e69 100644 --- a/homeassistant/components/opnsense/device_tracker.py +++ b/homeassistant/components/opnsense/device_tracker.py @@ -2,7 +2,7 @@ from typing import Any, NewType -from pyopnsense import diagnostics +from aiopnsense import OPNsenseClient from homeassistant.components.device_tracker import DeviceScanner from homeassistant.core import HomeAssistant @@ -27,9 +27,7 @@ async def async_get_scanner( class OPNsenseDeviceScanner(DeviceScanner): """This class queries a router running OPNsense.""" - def __init__( - self, client: diagnostics.InterfaceClient, interfaces: list[str] - ) -> None: + def __init__(self, client: OPNsenseClient, interfaces: list[str]) -> None: """Initialize the scanner.""" self.last_results: dict[str, Any] = {} self.client = client @@ -43,9 +41,9 @@ class OPNsenseDeviceScanner(DeviceScanner): out_devices[device["mac"]] = device return out_devices - def scan_devices(self) -> list[str]: + async def async_scan_devices(self) -> list[str]: """Scan for new devices and return a list with found device IDs.""" - self.update_info() + await self._async_update_info() return list(self.last_results) def get_device_name(self, device: str) -> str | None: @@ -54,12 +52,12 @@ class OPNsenseDeviceScanner(DeviceScanner): return None return self.last_results[device].get("hostname") or None - def update_info(self) -> bool: + async def _async_update_info(self) -> bool: """Ensure the information from the OPNsense router is up to date. Return boolean if scanning successful. """ - devices = self.client.get_arp() + devices = await self.client.get_arp_table(True) self.last_results = self._get_mac_addrs(devices) return True diff --git a/homeassistant/components/opnsense/manifest.json b/homeassistant/components/opnsense/manifest.json index 0a9aecbde25..b2d57e017c2 100644 --- a/homeassistant/components/opnsense/manifest.json +++ b/homeassistant/components/opnsense/manifest.json @@ -1,10 +1,11 @@ { "domain": "opnsense", "name": "OPNsense", - "codeowners": ["@mtreinish"], + "codeowners": ["@HarlemSquirrel", "@Snuffy2"], "documentation": "https://www.home-assistant.io/integrations/opnsense", + "integration_type": "hub", "iot_class": "local_polling", - "loggers": ["pbr", "pyopnsense"], + "loggers": ["aiopnsense"], "quality_scale": "legacy", - "requirements": ["pyopnsense==0.4.0"] + "requirements": ["aiopnsense==1.0.8"] } 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/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..f8c8490b508 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.2"] } diff --git a/homeassistant/components/opower/quality_scale.yaml b/homeassistant/components/opower/quality_scale.yaml index c51fa99c8ff..2df7bc5764d 100644 --- a/homeassistant/components/opower/quality_scale.yaml +++ b/homeassistant/components/opower/quality_scale.yaml @@ -42,8 +42,7 @@ rules: test-coverage: done # Gold - devices: - status: done + devices: done diagnostics: done discovery-update-info: status: exempt diff --git a/homeassistant/components/opower/repairs.py b/homeassistant/components/opower/repairs.py index f78dee32194..93a320311e4 100644 --- a/homeassistant/components/opower/repairs.py +++ b/homeassistant/components/opower/repairs.py @@ -1,7 +1,5 @@ """Repairs for Opower.""" -from __future__ import annotations - from homeassistant.components.repairs import RepairsFlow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 08341146a48..0ba7957cf15 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 diff --git a/homeassistant/components/opple/__init__.py b/homeassistant/components/opple/__init__.py index 41ef2b0fdd8..17b462d3ac3 100644 --- a/homeassistant/components/opple/__init__.py +++ b/homeassistant/components/opple/__init__.py @@ -1 +1 @@ -"""The opple component.""" +"""The Opple integration.""" 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/switch.py b/homeassistant/components/orvibo/switch.py index a7a829d7b66..d86bc0633f4 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 diff --git a/homeassistant/components/osoenergy/__init__.py b/homeassistant/components/osoenergy/__init__.py index ca6d52941f7..d4e4c881d8d 100644 --- a/homeassistant/components/osoenergy/__init__.py +++ b/homeassistant/components/osoenergy/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import DOMAIN +type OSOEnergyConfigEntry = ConfigEntry[OSOEnergy] PLATFORMS = [ Platform.BINARY_SENSOR, @@ -26,7 +26,7 @@ PLATFORM_LOOKUP = { } -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OSOEnergyConfigEntry) -> bool: """Set up OSO Energy from a config entry.""" subscription_key = entry.data[CONF_API_KEY] websession = aiohttp_client.async_get_clientsession(hass) @@ -34,8 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: osoenergy_config = dict(entry.data) - hass.data.setdefault(DOMAIN, {}) - try: devices: Any = await osoenergy.session.start_session(osoenergy_config) except HTTPException as error: @@ -43,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except OSOEnergyReauthRequired as err: raise ConfigEntryAuthFailed from err - hass.data[DOMAIN][entry.entry_id] = osoenergy + entry.runtime_data = osoenergy platforms = set() for ha_type, oso_type in PLATFORM_LOOKUP.items(): @@ -55,10 +53,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: OSOEnergyConfigEntry) -> 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/osoenergy/binary_sensor.py b/homeassistant/components/osoenergy/binary_sensor.py index a2ba61ccbe4..7ec6308e209 100644 --- a/homeassistant/components/osoenergy/binary_sensor.py +++ b/homeassistant/components/osoenergy/binary_sensor.py @@ -10,11 +10,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 . import OSOEnergyConfigEntry from .entity import OSOEnergyEntity @@ -46,11 +45,11 @@ SENSOR_TYPES: dict[str, OSOEnergyBinarySensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OSOEnergyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OSO Energy binary sensor.""" - osoenergy: OSOEnergy = hass.data[DOMAIN][entry.entry_id] + osoenergy = entry.runtime_data entities = [ OSOEnergyBinarySensor(osoenergy, sensor_type, dev) for dev in osoenergy.session.device_list.get("binary_sensor", []) diff --git a/homeassistant/components/osoenergy/sensor.py b/homeassistant/components/osoenergy/sensor.py index c2b1e75cd70..1b66f21e105 100644 --- a/homeassistant/components/osoenergy/sensor.py +++ b/homeassistant/components/osoenergy/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfEnergy, UnitOfPower, @@ -23,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from . import OSOEnergyConfigEntry from .entity import OSOEnergyEntity @@ -139,11 +138,11 @@ SENSOR_TYPES: dict[str, OSOEnergySensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OSOEnergyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OSO Energy sensor.""" - osoenergy = hass.data[DOMAIN][entry.entry_id] + osoenergy = entry.runtime_data devices = osoenergy.session.device_list.get("sensor") entities = [] if devices: diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py index 1f4ad9d06c5..e38e502fc76 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -15,7 +15,6 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.helpers import config_validation as cv, entity_platform @@ -23,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonValueType -from .const import DOMAIN +from . import OSOEnergyConfigEntry from .entity import OSOEnergyEntity ATTR_DURATION_DAYS = "duration_days" @@ -52,11 +51,11 @@ SERVICE_TURN_ON = "turn_on" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OSOEnergyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OSO Energy heater based on a config entry.""" - osoenergy = hass.data[DOMAIN][entry.entry_id] + osoenergy = entry.runtime_data devices = osoenergy.session.device_list.get("water_heater") if not devices: return 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..b6eccf73f90 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 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..4f6bbd231a2 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 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/ourgroceries/__init__.py b/homeassistant/components/ourgroceries/__init__.py index a83430b3531..7311077ab74 100644 --- a/homeassistant/components/ourgroceries/__init__.py +++ b/homeassistant/components/ourgroceries/__init__.py @@ -1,26 +1,22 @@ """The OurGroceries integration.""" -from __future__ import annotations - from aiohttp import ClientError from ourgroceries import OurGroceries from ourgroceries.exceptions import InvalidLoginException -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 DOMAIN -from .coordinator import OurGroceriesDataUpdateCoordinator +from .coordinator import OurGroceriesConfigEntry, OurGroceriesDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.TODO] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: OurGroceriesConfigEntry +) -> bool: """Set up OurGroceries from a config entry.""" - - hass.data.setdefault(DOMAIN, {}) data = entry.data og = OurGroceries(data[CONF_USERNAME], data[CONF_PASSWORD]) try: @@ -32,16 +28,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = OurGroceriesDataUpdateCoordinator(hass, entry, og) 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: OurGroceriesConfigEntry +) -> 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/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 a822931e88c..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 @@ -19,13 +17,19 @@ SCAN_INTERVAL = 60 _LOGGER = logging.getLogger(__name__) +type OurGroceriesConfigEntry = ConfigEntry[OurGroceriesDataUpdateCoordinator] + + class OurGroceriesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage fetching OurGroceries data.""" - config_entry: ConfigEntry + config_entry: OurGroceriesConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, og: OurGroceries + self, + hass: HomeAssistant, + config_entry: OurGroceriesConfigEntry, + og: OurGroceries, ) -> None: """Initialize global OurGroceries data updater.""" self.og = og diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py index f257ef481c7..eea7952eb4f 100644 --- a/homeassistant/components/ourgroceries/todo.py +++ b/homeassistant/components/ourgroceries/todo.py @@ -9,22 +9,20 @@ 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 OurGroceriesDataUpdateCoordinator +from .coordinator import OurGroceriesConfigEntry, OurGroceriesDataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OurGroceriesConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the OurGroceries todo platform config entry.""" - coordinator: OurGroceriesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( OurGroceriesTodoListEntity(coordinator, sl["id"], sl["name"]) for sl in coordinator.lists diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index 9fefed2792c..eeca97dc475 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 diff --git a/homeassistant/components/overkiz/alarm_control_panel.py b/homeassistant/components/overkiz/alarm_control_panel.py index 1a5490dd329..02e8e2a4792 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 diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py index 884a58092cb..9f9e37d43c7 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 diff --git a/homeassistant/components/overkiz/button.py b/homeassistant/components/overkiz/button.py index f4e051ef9ca..1fb0847a28e 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 diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py index 058c3aefdb7..297be8bff8f 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 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..989a4130532 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 diff --git a/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate/atlantic_electrical_towel_dryer.py index e0cfebc2449..305edcc5fbb 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 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..7759c2c1224 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 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..50129a01df3 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 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..700a18808e7 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 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..6aed2dce61e 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 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..47a42bc40d2 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 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..9c0ed61b0eb 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 diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index b04e9436c4d..bd76d1f783e 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 diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 99b7d48dcca..1e4044d6bf2 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 diff --git a/homeassistant/components/overkiz/coordinator.py b/homeassistant/components/overkiz/coordinator.py index 17b967fc0b9..1c7a7db1844 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 diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py new file mode 100644 index 00000000000..9eb3b3d079d --- /dev/null +++ b/homeassistant/components/overkiz/cover.py @@ -0,0 +1,669 @@ +"""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, + 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, + 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 remove open/close commands + # uiClass is VenetianBlind + OverkizCoverDescription( + key=UIWidget.TILT_ONLY_VENETIAN_BLIND, + device_class=CoverDeviceClass.BLIND, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + open_tilt_command=OverkizCommand.TILT_POSITIVE, + close_tilt_command=OverkizCommand.TILT_NEGATIVE, + 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 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_UNKNOWN, + 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_PEDESTRIAN, + stop_command=OverkizCommand.STOP, + ), + OverkizCoverDescription( + key=UIClass.PERGOLA, + device_class=CoverDeviceClass.AWNING, + 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, + ), + 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, + ), + 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, + open_command=OverkizCommand.OPEN, + close_command=OverkizCommand.CLOSE, + is_closed_state=OverkizState.CORE_OPEN_CLOSED, + 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): + 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 simultaneously. + + 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 the targeted position and the current one if the cover is moving.""" + 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 + + 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..0880440d393 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -1,7 +1,5 @@ """Parent class for every Overkiz device.""" -from __future__ import annotations - from typing import cast from pyoverkiz.enums import OverkizAttribute, OverkizState diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py index 220c6fe7cb2..111a6f318ec 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 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/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..0d61efca4e9 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 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..e67fabca8e9 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 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/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index 436180407f4..3b28655f620 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -1,31 +1,24 @@ """Support for OVO Energy.""" -from __future__ import annotations - -import asyncio -from datetime import timedelta import logging import aiohttp from ovoenergy import OVOEnergy -from ovoenergy.models import OVODailyUsage -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, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util -from .const import CONF_ACCOUNT, DATA_CLIENT, DATA_COORDINATOR, DOMAIN +from .const import CONF_ACCOUNT +from .coordinator import OVOEnergyConfigEntry, OVOEnergyDataUpdateCoordinator _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: OVOEnergyConfigEntry) -> bool: """Set up OVO Energy from a config entry.""" client = OVOEnergy( @@ -47,54 +40,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.warning(exception) raise ConfigEntryNotReady from exception - async def async_update_data() -> OVODailyUsage: - """Fetch data from OVO Energy.""" - if (custom_account := entry.data.get(CONF_ACCOUNT)) is not None: - client.custom_account_id = custom_account + coordinator = OVOEnergyDataUpdateCoordinator(hass, entry, client) - async with asyncio.timeout(10): - try: - authenticated = await client.authenticate( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - ) - except aiohttp.ClientError as exception: - raise UpdateFailed(exception) from exception - if not authenticated: - raise ConfigEntryAuthFailed("Not authenticated with OVO Energy") - return await client.get_daily_usage(dt_util.utcnow().strftime("%Y-%m")) - - coordinator = DataUpdateCoordinator[OVODailyUsage]( - hass, - _LOGGER, - config_entry=entry, - # Name of the data. For logging purposes. - name="sensor", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=3600), - ) - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_CLIENT: client, - DATA_COORDINATOR: coordinator, - } - - # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - # Setup components + 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: OVOEnergyConfigEntry) -> bool: """Unload OVO Energy config entry.""" - # Unload sensors - 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/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/const.py b/homeassistant/components/ovo_energy/const.py index 2d615e7c44a..e1cf957b992 100644 --- a/homeassistant/components/ovo_energy/const.py +++ b/homeassistant/components/ovo_energy/const.py @@ -2,6 +2,4 @@ DOMAIN = "ovo_energy" -DATA_CLIENT = "ovo_client" -DATA_COORDINATOR = "coordinator" CONF_ACCOUNT = "account" diff --git a/homeassistant/components/ovo_energy/coordinator.py b/homeassistant/components/ovo_energy/coordinator.py new file mode 100644 index 00000000000..1c1ac2374f1 --- /dev/null +++ b/homeassistant/components/ovo_energy/coordinator.py @@ -0,0 +1,61 @@ +"""Coordinator for the OVO Energy integration.""" + +import asyncio +from datetime import timedelta +import logging + +import aiohttp +from ovoenergy import OVOEnergy +from ovoenergy.models import OVODailyUsage + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +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 CONF_ACCOUNT + +_LOGGER = logging.getLogger(__name__) + +type OVOEnergyConfigEntry = ConfigEntry[OVOEnergyDataUpdateCoordinator] + + +class OVOEnergyDataUpdateCoordinator(DataUpdateCoordinator[OVODailyUsage]): + """Class to manage fetching OVO Energy data.""" + + config_entry: OVOEnergyConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: OVOEnergyConfigEntry, + client: OVOEnergy, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="sensor", + update_interval=timedelta(seconds=3600), + ) + self.client = client + + async def _async_update_data(self) -> OVODailyUsage: + """Fetch data from OVO Energy.""" + if (custom_account := self.config_entry.data.get(CONF_ACCOUNT)) is not None: + self.client.custom_account_id = custom_account + + async with asyncio.timeout(10): + try: + authenticated = await self.client.authenticate( + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + except aiohttp.ClientError as exception: + raise UpdateFailed(exception) from exception + if not authenticated: + raise ConfigEntryAuthFailed("Not authenticated with OVO Energy") + return await self.client.get_daily_usage(dt_util.utcnow().strftime("%Y-%m")) diff --git a/homeassistant/components/ovo_energy/entity.py b/homeassistant/components/ovo_energy/entity.py index ed8a24b0542..3e787303111 100644 --- a/homeassistant/components/ovo_energy/entity.py +++ b/homeassistant/components/ovo_energy/entity.py @@ -1,33 +1,17 @@ """Support for OVO Energy.""" -from __future__ import annotations - -from ovoenergy import OVOEnergy -from ovoenergy.models import OVODailyUsage - from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import OVOEnergyDataUpdateCoordinator -class OVOEnergyEntity(CoordinatorEntity[DataUpdateCoordinator[OVODailyUsage]]): +class OVOEnergyEntity(CoordinatorEntity[OVOEnergyDataUpdateCoordinator]): """Defines a base OVO Energy entity.""" _attr_has_entity_name = True - def __init__( - self, - coordinator: DataUpdateCoordinator[OVODailyUsage], - client: OVOEnergy, - ) -> None: - """Initialize the OVO Energy entity.""" - super().__init__(coordinator) - self._client = client - class OVOEnergyDeviceEntity(OVOEnergyEntity): """Defines a OVO Energy device entity.""" @@ -37,7 +21,7 @@ class OVOEnergyDeviceEntity(OVOEnergyEntity): """Return device information about this OVO Energy instance.""" return DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._client.account_id)}, + identifiers={(DOMAIN, self.coordinator.client.account_id)}, manufacturer="OVO Energy", - name=self._client.username, + name=self.coordinator.client.username, ) diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index e2ac9410cbc..ad895438b40 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -1,13 +1,10 @@ """Support for OVO Energy sensors.""" -from __future__ import annotations - from collections.abc import Callable import dataclasses from datetime import datetime, timedelta from typing import Final -from ovoenergy import OVOEnergy from ovoenergy.models import OVODailyUsage from homeassistant.components.sensor import ( @@ -16,15 +13,14 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN +from .const import DOMAIN +from .coordinator import OVOEnergyConfigEntry, OVOEnergyDataUpdateCoordinator from .entity import OVOEnergyDeviceEntity SCAN_INTERVAL = timedelta(seconds=300) @@ -114,14 +110,11 @@ SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: OVOEnergyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up OVO Energy sensor based on a config entry.""" - coordinator: DataUpdateCoordinator[OVODailyUsage] = hass.data[DOMAIN][ - entry.entry_id - ][DATA_COORDINATOR] - client: OVOEnergy = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + coordinator = entry.runtime_data entities = [] @@ -139,7 +132,7 @@ async def async_setup_entry( coordinator.data.electricity[-1].cost.currency_unit ), ) - entities.append(OVOEnergySensor(coordinator, description, client)) + entities.append(OVOEnergySensor(coordinator, description)) if coordinator.data.gas: for description in SENSOR_TYPES_GAS: if ( @@ -153,7 +146,7 @@ async def async_setup_entry( -1 ].cost.currency_unit, ) - entities.append(OVOEnergySensor(coordinator, description, client)) + entities.append(OVOEnergySensor(coordinator, description)) async_add_entities(entities, True) @@ -161,18 +154,18 @@ async def async_setup_entry( class OVOEnergySensor(OVOEnergyDeviceEntity, SensorEntity): """Define a OVO Energy sensor.""" - coordinator: DataUpdateCoordinator[DataUpdateCoordinator[OVODailyUsage]] entity_description: OVOEnergySensorEntityDescription def __init__( self, - coordinator: DataUpdateCoordinator[OVODailyUsage], + coordinator: OVOEnergyDataUpdateCoordinator, description: OVOEnergySensorEntityDescription, - client: OVOEnergy, ) -> None: """Initialize.""" - super().__init__(coordinator, client) - self._attr_unique_id = f"{DOMAIN}_{client.account_id}_{description.key}" + super().__init__(coordinator) + self._attr_unique_id = ( + f"{DOMAIN}_{coordinator.client.account_id}_{description.key}" + ) self.entity_description = description @property diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 623e5e17b66..0e07a1c1cec 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -1,4 +1,5 @@ """Support for OwnTracks.""" +# pylint: disable=hass-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..bdd69930fa6 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=hass-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/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 ff8461ad193..d4ef278705c 100644 --- a/homeassistant/components/palazzetti/quality_scale.yaml +++ b/homeassistant/components/palazzetti/quality_scale.yaml @@ -66,8 +66,7 @@ rules: entity-disabled-by-default: todo entity-translations: done exception-translations: done - icon-translations: - status: done + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt diff --git a/homeassistant/components/panasonic_bluray/__init__.py b/homeassistant/components/panasonic_bluray/__init__.py index a39b070b3c5..53c222ffc1f 100644 --- a/homeassistant/components/panasonic_bluray/__init__.py +++ b/homeassistant/components/panasonic_bluray/__init__.py @@ -1 +1 @@ -"""The panasonic_bluray component.""" +"""The Panasonic Blu-Ray Player integration.""" diff --git a/homeassistant/components/panasonic_bluray/media_player.py b/homeassistant/components/panasonic_bluray/media_player.py index 0a5e5d24b68..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 @@ -39,7 +37,7 @@ def setup_platform( add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Panasonic Blu-ray platform.""" + """Set up the Panasonic Blu-ray media player platform.""" conf = discovery_info or config # Register configured device with Home Assistant. @@ -59,7 +57,7 @@ class PanasonicBluRay(MediaPlayerEntity): ) def __init__(self, ip, name): - """Initialize the Panasonic Blue-ray device.""" + """Initialize the Panasonic Blu-ray device.""" self._device = PanasonicBD(ip) self._attr_name = name self._attr_state = MediaPlayerState.OFF diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py index 2d0a2b9d26b..1478b02095e 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -19,7 +19,6 @@ from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_DEVICE_INFO, - ATTR_REMOTE, ATTR_UDN, CONF_APP_ID, CONF_ENCRYPTION_KEY, @@ -29,6 +28,8 @@ from .const import ( DOMAIN, ) +type PanasonicVieraConfigEntry = ConfigEntry[Remote] + _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( @@ -68,10 +69,10 @@ 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: PanasonicVieraConfigEntry +) -> bool: """Set up Panasonic Viera from a config entry.""" - panasonic_viera_data = hass.data.setdefault(DOMAIN, {}) - config = config_entry.data host = config[CONF_HOST] @@ -88,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b remote = Remote(hass, host, port, on_action, **params) await remote.async_create_remote_control(during_setup=True) - panasonic_viera_data[config_entry.entry_id] = {ATTR_REMOTE: remote} + config_entry.runtime_data = remote # Add device_info to older config entries if ATTR_DEVICE_INFO not in config or config[ATTR_DEVICE_INFO] is None: @@ -112,15 +113,11 @@ 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: PanasonicVieraConfigEntry +) -> bool: """Unload a config entry.""" - 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) class Remote: diff --git a/homeassistant/components/panasonic_viera/config_flow.py b/homeassistant/components/panasonic_viera/config_flow.py index b00fee513a6..58baccf0dcc 100644 --- a/homeassistant/components/panasonic_viera/config_flow.py +++ b/homeassistant/components/panasonic_viera/config_flow.py @@ -168,5 +168,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=hass-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/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index a78920f33a5..dc7f859c63a 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,17 +15,16 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import PanasonicVieraConfigEntry from .const import ( ATTR_DEVICE_INFO, ATTR_MANUFACTURER, ATTR_MODEL_NUMBER, - ATTR_REMOTE, ATTR_UDN, DEFAULT_MANUFACTURER, DEFAULT_MODEL_NUMBER, @@ -39,14 +36,14 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PanasonicVieraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Panasonic Viera TV from a config entry.""" config = config_entry.data - remote = hass.data[DOMAIN][config_entry.entry_id][ATTR_REMOTE] + remote = config_entry.runtime_data name = config[CONF_NAME] device_info = config[ATTR_DEVICE_INFO] diff --git a/homeassistant/components/panasonic_viera/remote.py b/homeassistant/components/panasonic_viera/remote.py index 5fa4be9ca2b..9c8757cd13a 100644 --- a/homeassistant/components/panasonic_viera/remote.py +++ b/homeassistant/components/panasonic_viera/remote.py @@ -1,23 +1,19 @@ """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.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import Remote +from . import PanasonicVieraConfigEntry, Remote from .const import ( ATTR_DEVICE_INFO, ATTR_MANUFACTURER, ATTR_MODEL_NUMBER, - ATTR_REMOTE, ATTR_UDN, DEFAULT_MANUFACTURER, DEFAULT_MODEL_NUMBER, @@ -27,14 +23,14 @@ from .const import ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PanasonicVieraConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Panasonic Viera TV Remote from a config entry.""" config = config_entry.data - remote = hass.data[DOMAIN][config_entry.entry_id][ATTR_REMOTE] + remote = config_entry.runtime_data name = config[CONF_NAME] device_info = config[ATTR_DEVICE_INFO] diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py index db9c35a7608..02254b8a90e 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 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..150ba14d047 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 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..fa354ff367e 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 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..01e9530db67 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 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 9dd32ecf14c..6caa4099e6f 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -1,40 +1,40 @@ """The PECO Outage Counter integration.""" -from __future__ import annotations - from typing import Final -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import CONF_PHONE_NUMBER, DOMAIN -from .coordinator import PecoOutageCoordinator, PecoSmartMeterCoordinator +from .const import CONF_PHONE_NUMBER +from .coordinator import ( + PecoConfigEntry, + PecoOutageCoordinator, + PecoRuntimeData, + PecoSmartMeterCoordinator, +) PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PecoConfigEntry) -> bool: """Set up PECO Outage Counter from a config entry.""" outage_coordinator = PecoOutageCoordinator(hass, entry) await outage_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "outage_count": outage_coordinator - } - + meter_coordinator: PecoSmartMeterCoordinator | None = None if phone_number := entry.data.get(CONF_PHONE_NUMBER): meter_coordinator = PecoSmartMeterCoordinator(hass, entry, phone_number) await meter_coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id]["smart_meter"] = meter_coordinator + + entry.runtime_data = PecoRuntimeData( + outage_coordinator=outage_coordinator, + meter_coordinator=meter_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: PecoConfigEntry) -> 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/peco/binary_sensor.py b/homeassistant/components/peco/binary_sensor.py index 86ec12a3999..cf2c018de15 100644 --- a/homeassistant/components/peco/binary_sensor.py +++ b/homeassistant/components/peco/binary_sensor.py @@ -1,35 +1,28 @@ """Binary sensor for PECO outage counter.""" -from __future__ import annotations - from typing import Final 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 homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import PecoSmartMeterCoordinator +from .coordinator import PecoConfigEntry, PecoSmartMeterCoordinator PARALLEL_UPDATES: Final = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PecoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary sensor for PECO.""" - if "smart_meter" not in hass.data[DOMAIN][config_entry.entry_id]: + if (coordinator := config_entry.runtime_data.meter_coordinator) is None: return - coordinator: PecoSmartMeterCoordinator = hass.data[DOMAIN][config_entry.entry_id][ - "smart_meter" - ] async_add_entities( [PecoBinarySensor(coordinator, phone_number=config_entry.data["phone_number"])] diff --git a/homeassistant/components/peco/config_flow.py b/homeassistant/components/peco/config_flow.py index a5e8f4451fd..3c9d689d982 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 diff --git a/homeassistant/components/peco/coordinator.py b/homeassistant/components/peco/coordinator.py index 0ecc6d23ef2..97e894d96f6 100644 --- a/homeassistant/components/peco/coordinator.py +++ b/homeassistant/components/peco/coordinator.py @@ -28,12 +28,23 @@ class PECOCoordinatorData: alerts: AlertResults +@dataclass +class PecoRuntimeData: + """Runtime data for the PECO integration.""" + + outage_coordinator: PecoOutageCoordinator + meter_coordinator: PecoSmartMeterCoordinator | None = None + + +type PecoConfigEntry = ConfigEntry[PecoRuntimeData] + + class PecoOutageCoordinator(DataUpdateCoordinator[PECOCoordinatorData]): """Coordinator for PECO outage data.""" - config_entry: ConfigEntry + config_entry: PecoConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: PecoConfigEntry) -> None: """Initialize the outage coordinator.""" super().__init__( hass, @@ -65,10 +76,10 @@ class PecoOutageCoordinator(DataUpdateCoordinator[PECOCoordinatorData]): class PecoSmartMeterCoordinator(DataUpdateCoordinator[bool]): """Coordinator for PECO smart meter data.""" - config_entry: ConfigEntry + config_entry: PecoConfigEntry def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, phone_number: str + self, hass: HomeAssistant, entry: PecoConfigEntry, phone_number: str ) -> None: """Initialize the smart meter coordinator.""" super().__init__( diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index a376fa8fc5a..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 @@ -11,7 +9,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -19,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_CONTENT, CONF_COUNTY, DOMAIN -from .coordinator import PECOCoordinatorData, PecoOutageCoordinator +from .coordinator import PecoConfigEntry, PECOCoordinatorData, PecoOutageCoordinator @dataclass(frozen=True, kw_only=True) @@ -72,12 +69,12 @@ SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PecoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" county: str = config_entry.data[CONF_COUNTY] - coordinator = hass.data[DOMAIN][config_entry.entry_id]["outage_count"] + coordinator = config_entry.runtime_data.outage_coordinator async_add_entities( PecoSensor(sensor, county, coordinator) for sensor in SENSOR_LIST 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 441c6a2646e..ff3127d75a8 100644 --- a/homeassistant/components/permobil/__init__.py +++ b/homeassistant/components/permobil/__init__.py @@ -1,12 +1,9 @@ """The MyPermobil integration.""" -from __future__ import annotations - import logging from mypermobil import MyPermobil, MyPermobilClientException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CODE, CONF_EMAIL, @@ -19,15 +16,15 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import APPLICATION, DOMAIN -from .coordinator import MyPermobilCoordinator +from .const import APPLICATION +from .coordinator import MyPermobilCoordinator, PermobilConfigEntry PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PermobilConfigEntry) -> bool: """Set up MyPermobil from a config entry.""" # create the API object from the config and save it in hass @@ -51,15 +48,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = MyPermobilCoordinator(hass, entry, p_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: PermobilConfigEntry) -> 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/permobil/binary_sensor.py b/homeassistant/components/permobil/binary_sensor.py index c2d51067e19..4d85f91d0ee 100644 --- a/homeassistant/components/permobil/binary_sensor.py +++ b/homeassistant/components/permobil/binary_sensor.py @@ -1,14 +1,11 @@ """Platform for binary sensor integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Any from mypermobil import BATTERY_CHARGING -from homeassistant import config_entries from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, @@ -16,8 +13,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import MyPermobilCoordinator +from .coordinator import PermobilConfigEntry from .entity import PermobilEntity @@ -41,12 +37,12 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[PermobilBinarySensorEntityDescription, ...] = async def async_setup_entry( hass: HomeAssistant, - config_entry: config_entries.ConfigEntry, + config_entry: PermobilConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create and setup the binary sensor.""" - coordinator: MyPermobilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( PermobilbinarySensor(coordinator=coordinator, description=description) 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/coordinator.py b/homeassistant/components/permobil/coordinator.py index ea7ddadff9f..16a5d93751d 100644 --- a/homeassistant/components/permobil/coordinator.py +++ b/homeassistant/components/permobil/coordinator.py @@ -13,6 +13,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) +type PermobilConfigEntry = ConfigEntry[MyPermobilCoordinator] + @dataclass class MyPermobilData: @@ -26,10 +28,10 @@ class MyPermobilData: class MyPermobilCoordinator(DataUpdateCoordinator[MyPermobilData]): """MyPermobil coordinator.""" - config_entry: ConfigEntry + config_entry: PermobilConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, p_api: MyPermobil + self, hass: HomeAssistant, config_entry: PermobilConfigEntry, p_api: MyPermobil ) -> None: """Initialize my coordinator.""" super().__init__( diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py index 8445bf8b446..e173265a28a 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 @@ -23,7 +21,6 @@ from mypermobil import ( USAGE_DISTANCE, ) -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -34,8 +31,8 @@ from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfTi from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import BATTERY_ASSUMED_VOLTAGE, DOMAIN, KM, MILES -from .coordinator import MyPermobilCoordinator +from .const import BATTERY_ASSUMED_VOLTAGE, KM, MILES +from .coordinator import PermobilConfigEntry from .entity import PermobilEntity _LOGGER = logging.getLogger(__name__) @@ -176,12 +173,12 @@ DISTANCE_UNITS: dict[Any, UnitOfLength] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: config_entries.ConfigEntry, + config_entry: PermobilConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create sensors from a config entry created in the integrations UI.""" - coordinator: MyPermobilCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( PermobilSensor(coordinator=coordinator, description=description) 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/strings.json b/homeassistant/components/persistent_notification/strings.json index e2271dd7bf6..c6e98b48447 100644 --- a/homeassistant/components/persistent_notification/strings.json +++ b/homeassistant/components/persistent_notification/strings.json @@ -1,7 +1,7 @@ { "services": { "create": { - "description": "Shows a notification on the notifications panel.", + "description": "Shows a persistent notification on the notifications panel.", "fields": { "message": { "description": "Message body of the notification.", @@ -16,21 +16,21 @@ "name": "Title" } }, - "name": "Create" + "name": "Create persistent notification" }, "dismiss": { - "description": "Deletes a notification from the notifications panel.", + "description": "Deletes a persistent notification from the notifications panel.", "fields": { "notification_id": { "description": "ID of the notification to be deleted.", "name": "[%key:component::persistent_notification::services::create::fields::notification_id::name%]" } }, - "name": "Dismiss" + "name": "Dismiss persistent notification" }, "dismiss_all": { - "description": "Deletes all notifications from the notifications panel.", - "name": "Dismiss all" + "description": "Deletes all persistent notifications from the notifications panel.", + "name": "Dismiss all persistent notifications" } }, "title": "Persistent Notification" 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 d67f45d1540..6701822ec7b 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 @@ -11,6 +9,7 @@ import voluptuous as vol from homeassistant.auth import EVENT_USER_REMOVED from homeassistant.components import persistent_notification, websocket_api from homeassistant.components.device_tracker import ( + ATTR_IN_ZONES, ATTR_SOURCE_TYPE, DOMAIN as DEVICE_TRACKER_DOMAIN, SourceType, @@ -51,7 +50,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 @@ -92,7 +90,6 @@ CONFIG_SCHEMA = vol.Schema( ) -@bind_hass async def async_create_person( hass: HomeAssistant, name: str, @@ -110,7 +107,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: @@ -435,6 +431,7 @@ class Person( self._unsub_track_device: Callable[[], None] | None = None self._attr_state: str | None = None self.device_trackers: list[str] = [] + self._in_zones: list[str] = [] self._attr_unique_id = config[CONF_ID] self._set_attrs_from_config() @@ -552,6 +549,7 @@ class Person( self._latitude = None self._longitude = None self._gps_accuracy = None + self._in_zones = [] self._update_extra_state_attributes() self.async_write_ha_state() @@ -566,7 +564,8 @@ class Person( self._source = state.entity_id self._latitude = coordinates.attributes.get(ATTR_LATITUDE) self._longitude = coordinates.attributes.get(ATTR_LONGITUDE) - self._gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY) + self._gps_accuracy = coordinates.attributes.get(ATTR_GPS_ACCURACY) + self._in_zones = coordinates.attributes.get(ATTR_IN_ZONES, []) @callback def _update_extra_state_attributes(self) -> None: @@ -575,6 +574,7 @@ class Person( ATTR_EDITABLE: self.editable, ATTR_ID: self.unique_id, ATTR_DEVICE_TRACKERS: self.device_trackers, + ATTR_IN_ZONES: self._in_zones, } if self._latitude is not 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 24b430197f8..385bad47cb4 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -1,32 +1,4 @@ { - "common": { - "condition_behavior_description": "How the state should match on the targeted persons.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted persons to trigger on.", - "trigger_behavior_name": "Behavior" - }, - "conditions": { - "is_home": { - "description": "Tests if one or more persons are home.", - "fields": { - "behavior": { - "description": "[%key:component::person::common::condition_behavior_description%]", - "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": { - "description": "[%key:component::person::common::condition_behavior_description%]", - "name": "[%key:component::person::common::condition_behavior_name%]" - } - }, - "name": "Person is not home" - } - }, "entity_component": { "_": { "name": "[%key:component::person::title%]", @@ -53,48 +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": { - "description": "[%key:component::person::common::trigger_behavior_description%]", - "name": "[%key:component::person::common::trigger_behavior_name%]" - } - }, - "name": "Entered home" - }, - "left_home": { - "description": "Triggers when one or more persons leave home.", - "fields": { - "behavior": { - "description": "[%key:component::person::common::trigger_behavior_description%]", - "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..c1435aa975c 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 diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py index c0a02f4f835..353fac32ace 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 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..dabf3d4cf37 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 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..b06c506aa6c 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 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..401dc7a3bd7 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 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..5bb9b7acc5b 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 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..f0a7584cef2 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 diff --git a/homeassistant/components/pi_hole/coordinator.py b/homeassistant/components/pi_hole/coordinator.py index 36cf64f345a..d25dc73c79a 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 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..fe369facc0b 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 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/__init__.py b/homeassistant/components/picnic/__init__.py index bf9bb61b539..9bd76865c71 100644 --- a/homeassistant/components/picnic/__init__.py +++ b/homeassistant/components/picnic/__init__.py @@ -2,14 +2,13 @@ from python_picnic_api2 import PicnicAPI -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import CONF_API, CONF_COORDINATOR, DOMAIN -from .coordinator import PicnicUpdateCoordinator +from .const import DOMAIN +from .coordinator import PicnicConfigEntry, PicnicUpdateCoordinator from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -24,7 +23,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -def create_picnic_client(entry: ConfigEntry): +def create_picnic_client(entry: PicnicConfigEntry): """Create an instance of the PicnicAPI client.""" return PicnicAPI( auth_token=entry.data.get(CONF_ACCESS_TOKEN), @@ -32,7 +31,7 @@ def create_picnic_client(entry: ConfigEntry): ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PicnicConfigEntry) -> bool: """Set up Picnic from a config entry.""" picnic_client = await hass.async_add_executor_job(create_picnic_client, entry) picnic_coordinator = PicnicUpdateCoordinator(hass, picnic_client, entry) @@ -40,21 +39,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Fetch initial data so we have data when entities subscribe await picnic_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - CONF_API: picnic_client, - CONF_COORDINATOR: picnic_coordinator, - } + entry.runtime_data = picnic_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: PicnicConfigEntry) -> 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/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index a60086173a8..c5d8e068eee 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,122 @@ 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 f8737806746..0c7336263d3 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -1,12 +1,7 @@ """Constants for the Picnic integration.""" -from __future__ import annotations - DOMAIN = "picnic" -CONF_API = "api" -CONF_COORDINATOR = "coordinator" - SERVICE_ADD_PRODUCT_TO_CART = "add_product" ATTR_PRODUCT_ID = "product_id" @@ -15,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 a63be7614c2..ac971443d6e 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -17,17 +17,19 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ADDRESS, CART_DATA, LAST_ORDER_DATA, NEXT_DELIVERY_DATA, SLOT_DATA +type PicnicConfigEntry = ConfigEntry[PicnicUpdateCoordinator] + class PicnicUpdateCoordinator(DataUpdateCoordinator): """The coordinator to fetch data from the Picnic API at a set interval.""" - config_entry: ConfigEntry + config_entry: PicnicConfigEntry def __init__( self, hass: HomeAssistant, picnic_api_client: PicnicAPI, - config_entry: ConfigEntry, + config_entry: PicnicConfigEntry, ) -> None: """Initialize the coordinator with the given Picnic API client.""" self.picnic_api_client = picnic_api_client @@ -45,8 +47,6 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> dict: """Fetch data from API endpoint.""" try: - # Note: TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. async with asyncio.timeout(10): data = await self.hass.async_add_executor_job(self.fetch_data) @@ -56,6 +56,10 @@ class PicnicUpdateCoordinator(DataUpdateCoordinator): raise UpdateFailed(f"API response was malformed: {error}") from error except PicnicAuthError as error: raise ConfigEntryAuthFailed from error + except TimeoutError as error: + raise UpdateFailed( + "Timeout while connecting to the Picnic API", retry_after=120 + ) from error # Return the fetched data return data diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json index c1bc18b6c65..d75b145aecf 100644 --- a/homeassistant/components/picnic/manifest.json +++ b/homeassistant/components/picnic/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["python_picnic_api2"], - "requirements": ["python-picnic-api2==1.3.1"] + "requirements": ["python-picnic-api2==1.3.4"] } diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index dcfd9086491..bb5c2aa468e 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 @@ -12,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CURRENCY_EURO from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -23,7 +20,6 @@ from homeassistant.util import dt as dt_util from .const import ( ATTRIBUTION, - CONF_COORDINATOR, DOMAIN, SENSOR_CART_ITEMS_COUNT, SENSOR_CART_TOTAL_PRICE, @@ -42,7 +38,7 @@ from .const import ( SENSOR_SELECTED_SLOT_MIN_ORDER_VALUE, SENSOR_SELECTED_SLOT_START, ) -from .coordinator import PicnicUpdateCoordinator +from .coordinator import PicnicConfigEntry, PicnicUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -202,11 +198,11 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PicnicConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Picnic sensor entries.""" - picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] + picnic_coordinator = config_entry.runtime_data # Add an entity for each sensor type async_add_entities( @@ -225,7 +221,7 @@ class PicnicSensor(SensorEntity, CoordinatorEntity[PicnicUpdateCoordinator]): def __init__( self, coordinator: PicnicUpdateCoordinator, - config_entry: ConfigEntry, + config_entry: PicnicConfigEntry, description: PicnicSensorEntityDescription, ) -> None: """Init a Picnic sensor.""" diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index d0465fcc13c..37063ca317d 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -1,12 +1,11 @@ """Services for the Picnic integration.""" -from __future__ import annotations - from typing import cast from python_picnic_api2 import PicnicAPI import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv @@ -16,10 +15,10 @@ from .const import ( ATTR_PRODUCT_ID, ATTR_PRODUCT_IDENTIFIERS, ATTR_PRODUCT_NAME, - CONF_API, DOMAIN, SERVICE_ADD_PRODUCT_TO_CART, ) +from .coordinator import PicnicConfigEntry class PicnicServiceException(Exception): @@ -50,10 +49,14 @@ def async_setup_services(hass: HomeAssistant) -> None: async def get_api_client(hass: HomeAssistant, config_entry_id: str) -> PicnicAPI: - """Get the right Picnic API client based on the device id, else get the default one.""" - if config_entry_id not in hass.data[DOMAIN]: + """Get the right Picnic API client based on the config entry id.""" + + entry: PicnicConfigEntry | None = hass.config_entries.async_get_entry( + config_entry_id + ) + if entry is None or entry.state != ConfigEntryState.LOADED: raise ValueError(f"Config entry with id {config_entry_id} not found!") - return hass.data[DOMAIN][config_entry_id][CONF_API] + return entry.runtime_data.picnic_api_client async def handle_add_product( 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 383c236de3c..c8c95d2d47c 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 @@ -11,15 +9,14 @@ from homeassistant.components.todo import ( TodoListEntity, TodoListEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError 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_COORDINATOR, DOMAIN -from .coordinator import PicnicUpdateCoordinator +from .const import DOMAIN +from .coordinator import PicnicConfigEntry, PicnicUpdateCoordinator from .services import product_search _LOGGER = logging.getLogger(__name__) @@ -27,11 +24,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PicnicConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Picnic shopping cart todo platform config entry.""" - picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR] + picnic_coordinator = config_entry.runtime_data async_add_entities([PicnicCart(picnic_coordinator, config_entry)]) @@ -46,7 +43,7 @@ class PicnicCart(TodoListEntity, CoordinatorEntity[PicnicUpdateCoordinator]): def __init__( self, coordinator: PicnicUpdateCoordinator, - config_entry: ConfigEntry, + config_entry: PicnicConfigEntry, ) -> None: """Initialize PicnicCart.""" super().__init__(coordinator) diff --git a/homeassistant/components/picotts/__init__.py b/homeassistant/components/picotts/__init__.py index 7ffc80db2f9..03898bbac0b 100644 --- a/homeassistant/components/picotts/__init__.py +++ b/homeassistant/components/picotts/__init__.py @@ -1 +1,29 @@ -"""Support for pico integration.""" +"""The Pico TTS integration.""" + +import shutil + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.TTS] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Pico TTS from a config entry.""" + if await hass.async_add_executor_job(shutil.which, "pico2wave") is None: + raise ConfigEntryError( + translation_domain=DOMAIN, translation_key="binary_not_found" + ) + + 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/picotts/config_flow.py b/homeassistant/components/picotts/config_flow.py new file mode 100644 index 00000000000..88335d7e005 --- /dev/null +++ b/homeassistant/components/picotts/config_flow.py @@ -0,0 +1,47 @@ +"""Config flow for Pico TTS integration.""" + +import shutil +from typing import Any + +import voluptuous as vol + +from homeassistant.components.tts import CONF_LANG +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import DEFAULT_LANG, DOMAIN, SUPPORT_LANGUAGES + +STEP_USER_DATA_SCHEMA = vol.Schema( + {vol.Required(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES)} +) + + +class PicoTTSConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Pico TTS.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if await self.hass.async_add_executor_job(shutil.which, "pico2wave") is None: + return self.async_abort(reason="binary_not_found") + + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + language = user_input[CONF_LANG] + + self._async_abort_entries_match({CONF_LANG: language}) + + title = f"Pico TTS {language}" + data = { + CONF_LANG: language, + } + + return self.async_create_entry(title=title, data=data) + + async def async_step_import(self, import_info: dict[str, Any]) -> ConfigFlowResult: + """Import Pico TTS config from yaml.""" + + return await self.async_step_user(import_info) diff --git a/homeassistant/components/picotts/const.py b/homeassistant/components/picotts/const.py new file mode 100644 index 00000000000..055577ee7ef --- /dev/null +++ b/homeassistant/components/picotts/const.py @@ -0,0 +1,6 @@ +"""Constants for the Pico TTS integration.""" + +DEFAULT_LANG = "en-US" +DOMAIN = "picotts" + +SUPPORT_LANGUAGES = ["en-US", "en-GB", "de-DE", "es-ES", "fr-FR", "it-IT"] diff --git a/homeassistant/components/picotts/issue.py b/homeassistant/components/picotts/issue.py new file mode 100644 index 00000000000..c932a5b8ff4 --- /dev/null +++ b/homeassistant/components/picotts/issue.py @@ -0,0 +1,25 @@ +"""Issues for Pico TTS integration.""" + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN + + +@callback +def deprecate_yaml_issue(hass: HomeAssistant) -> None: + """Deprecate yaml issue.""" + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + breaks_in_ha_version="2026.10.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Pico TTS", + }, + ) diff --git a/homeassistant/components/picotts/manifest.json b/homeassistant/components/picotts/manifest.json index 6e8c346a3c9..20ca5487e94 100644 --- a/homeassistant/components/picotts/manifest.json +++ b/homeassistant/components/picotts/manifest.json @@ -1,8 +1,9 @@ { "domain": "picotts", "name": "Pico TTS", - "codeowners": [], + "codeowners": ["@rooggiieerr"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/picotts", - "iot_class": "local_push", - "quality_scale": "legacy" + "integration_type": "service", + "iot_class": "local_push" } diff --git a/homeassistant/components/picotts/strings.json b/homeassistant/components/picotts/strings.json new file mode 100644 index 00000000000..fbe61831809 --- /dev/null +++ b/homeassistant/components/picotts/strings.json @@ -0,0 +1,38 @@ +{ + "common": { + "binary_not_found": "pico2wave binary could not be found" + }, + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "binary_not_found": "[%key:component::picotts::common::binary_not_found%]" + }, + "step": { + "user": { + "data": { + "language": "[%key:common::config_flow::data::language%]" + } + } + } + }, + "exceptions": { + "binary_not_found": { + "message": "[%key:component::picotts::common::binary_not_found%]" + }, + "file_read_error": { + "message": "Error trying to read {filename}" + }, + "returncode_error": { + "message": "Error running pico2wave, return code: {returncode}" + }, + "timeout_error": { + "message": "Timeout running pico2wave" + } + }, + "issues": { + "deprecated_yaml": { + "description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nThe actions `tts.{domain}_*_say` will be removed and automations should be updated to use the `tts.speak` action with the new tts entities. Then remove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue.", + "title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]" + } + } +} diff --git a/homeassistant/components/picotts/tts.py b/homeassistant/components/picotts/tts.py index 11cb2d7f557..90ae5847b7c 100644 --- a/homeassistant/components/picotts/tts.py +++ b/homeassistant/components/picotts/tts.py @@ -1,5 +1,6 @@ """Support for the Pico TTS speech service.""" +import contextlib import logging import os import shutil @@ -13,32 +14,114 @@ from homeassistant.components.tts import ( CONF_LANG, PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA, Provider, + TextToSpeechEntity, TtsAudioType, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +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.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import DEFAULT_LANG, DOMAIN, SUPPORT_LANGUAGES +from .issue import deprecate_yaml_issue _LOGGER = logging.getLogger(__name__) -SUPPORT_LANGUAGES = ["en-US", "en-GB", "de-DE", "es-ES", "fr-FR", "it-IT"] - -DEFAULT_LANG = "en-US" - PLATFORM_SCHEMA = TTS_PLATFORM_SCHEMA.extend( {vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES)} ) -def get_engine(hass, config, discovery_info=None): +async def async_get_engine( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> Provider | None: """Set up Pico speech component.""" - if shutil.which("pico2wave") is None: + if await hass.async_add_executor_job(shutil.which, "pico2wave") is None: _LOGGER.error("'pico2wave' was not found") - return False + return None + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + + deprecate_yaml_issue(hass) + return PicoProvider(config[CONF_LANG]) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Pico TTS speech component via config entry.""" + async_add_entities([PicoTTSEntity(config_entry, config_entry.data[CONF_LANG])]) + + +class PicoTTSEntity(TextToSpeechEntity): + """The Pico TTS API entity.""" + + _attr_supported_languages = SUPPORT_LANGUAGES + + def __init__(self, config_entry: ConfigEntry, lang: str) -> None: + """Initialize Pico TTS service.""" + self._attr_default_language = lang + self._attr_name = f"Pico TTS {lang}" + self._attr_unique_id = config_entry.entry_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, config_entry.entry_id)}, + model="Pico TTS", + name=f"Pico TTS {lang}", + ) + + def get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load TTS using pico2wave.""" + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpf: + fname = tmpf.name + + cmd = ["pico2wave", "--wave", fname, "-l", language] + try: + subprocess.run(cmd, text=True, input=message, check=True, timeout=30) + with open(fname, "rb") as voice: + data = voice.read() + except subprocess.CalledProcessError as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="returncode_error", + translation_placeholders={"returncode": str(exc.returncode)}, + ) from exc + except subprocess.TimeoutExpired as exc: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_error", + ) from exc + except OSError as exc: + _LOGGER.debug("Full exception %s", exc) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="file_read_error", + translation_placeholders={"filename": fname}, + ) from exc + finally: + with contextlib.suppress(OSError): + os.remove(fname) + + return "wav", data + + class PicoProvider(Provider): """The Pico TTS API provider.""" - def __init__(self, lang): + def __init__(self, lang: str) -> None: """Initialize Pico TTS provider.""" self._lang = lang self.name = "PicoTTS" @@ -68,15 +151,15 @@ class PicoProvider(Provider): _LOGGER.error( "Error running pico2wave, return code: %s", result.returncode ) - return (None, None) + return None, None with open(fname, "rb") as voice: data = voice.read() except OSError: _LOGGER.error("Error trying to read %s", fname) - return (None, None) + return None, None finally: os.remove(fname) if data: return ("wav", data) - return (None, None) + return None, None diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py index 2e5d7ffe5e8..50289ff2e96 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 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/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..421f4cd2bb3 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 @@ -210,6 +208,8 @@ class PlaatoOptionsFlowHandler(OptionsFlow): step_id="user", data_schema=vol.Schema( { + # Polling interval is user-configurable, which is no longer allowed + # pylint: disable-next=hass-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..31a3654ca21 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,14 +17,20 @@ 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() sw_version = None @@ -58,7 +56,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/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..78575192996 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 diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index d456cc110a4..7989c3f696f 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 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/notify.py b/homeassistant/components/playstation_network/notify.py index 8eabb5a78f6..46a3f7550ba 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 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..12bb96d696f 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 diff --git a/homeassistant/components/plex/helpers.py b/homeassistant/components/plex/helpers.py index 3c7ff8180c8..d81f57b86de 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=hass-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/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..8b07b92d645 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 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..579c84ee677 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 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/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/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..4d53dae405f 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 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..9a93ca128d3 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 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..8591fa73aec 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 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..5f87b8caced 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 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 67656c9d6e1..e8e8a5ea416 100644 --- a/homeassistant/components/pooldose/strings.json +++ b/homeassistant/components/pooldose/strings.json @@ -346,7 +346,7 @@ }, "exceptions": { "cannot_connect": { - "message": "Value can not be set because the device is not connected" + "message": "Value cannot be set because the device is not connected" }, "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..4de10fe4f1e 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 diff --git a/homeassistant/components/portainer/binary_sensor.py b/homeassistant/components/portainer/binary_sensor.py index 787656b0268..bd0b5047cf3 100644 --- a/homeassistant/components/portainer/binary_sensor.py +++ b/homeassistant/components/portainer/binary_sensor.py @@ -1,10 +1,10 @@ """Binary sensor platform for Portainer.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass +from pyportainer import DockerContainerState, EndpointStatus, StackStatus + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -15,7 +15,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PortainerConfigEntry -from .const import ContainerState, EndpointStatus, StackStatus from .coordinator import PortainerContainerData from .entity import ( PortainerContainerEntity, @@ -53,7 +52,7 @@ CONTAINER_SENSORS: tuple[PortainerContainerBinarySensorEntityDescription, ...] = PortainerContainerBinarySensorEntityDescription( key="status", translation_key="status", - state_fn=lambda data: data.container.state == ContainerState.RUNNING, + state_fn=lambda data: data.container.state == DockerContainerState.RUNNING, device_class=BinarySensorDeviceClass.RUNNING, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py index daa17452379..7784f1ebb56 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], ] @@ -60,6 +58,14 @@ ENDPOINT_BUTTONS: tuple[PortainerButtonDescription, ...] = ( ) ), ), + PortainerButtonDescription( + key="volumes_prune", + translation_key="volumes_prune", + entity_category=EntityCategory.CONFIG, + press_action=( + lambda portainer, endpoint_id, _: portainer.prune_volumes(endpoint_id) + ), + ), ) CONTAINER_BUTTONS: tuple[PortainerButtonDescription, ...] = ( @@ -94,6 +100,29 @@ 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", + entity_category=EntityCategory.CONFIG, + press_action=( + lambda portainer, endpoint_id, container_id: portainer.kill_container( + endpoint_id, container_id + ) + ), + ), ) @@ -181,6 +210,8 @@ class PortainerBaseButton(ButtonEntity): translation_key="timeout_connect_no_details", ) from err + await self.coordinator.async_request_refresh() + class PortainerEndpointButton(PortainerEndpointEntity, PortainerBaseButton): """Defines a Portainer endpoint button.""" 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/const.py b/homeassistant/components/portainer/const.py index 8c1f1fa9d09..ae8e8ee98df 100644 --- a/homeassistant/components/portainer/const.py +++ b/homeassistant/components/portainer/const.py @@ -1,36 +1,6 @@ """Constants for the Portainer integration.""" -from enum import IntEnum, StrEnum - DOMAIN = "portainer" DEFAULT_NAME = "Portainer" API_MAX_RETRIES = 3 - - -class EndpointStatus(IntEnum): - """Portainer endpoint status.""" - - UP = 1 - DOWN = 2 - - -class ContainerState(StrEnum): - """Portainer container state.""" - - RUNNING = "running" - - -class StackStatus(IntEnum): - """Portainer stack status.""" - - ACTIVE = 1 - INACTIVE = 2 - - -class StackType(IntEnum): - """Portainer stack type.""" - - SWARM = 1 - COMPOSE = 2 - KUBERNETES = 3 diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index a9f6a23a822..84dd7b24a41 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -1,7 +1,5 @@ """Data Update Coordinator for Portainer.""" -from __future__ import annotations - import asyncio from collections.abc import Callable from dataclasses import dataclass @@ -9,6 +7,8 @@ from datetime import timedelta import logging from pyportainer import ( + DockerContainerState, + EndpointStatus, Portainer, PortainerAuthenticationError, PortainerConnectionError, @@ -18,6 +18,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 @@ -26,10 +28,10 @@ from pyportainer.models.stacks import Stack from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, ContainerState, EndpointStatus +from .const import DOMAIN type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] @@ -50,6 +52,7 @@ class PortainerCoordinatorData: docker_info: DockerInfo docker_system_df: DockerSystemDF stacks: dict[str, PortainerStackData] + volumes: dict[str, PortainerVolumeData] @dataclass(slots=True) @@ -70,6 +73,13 @@ class PortainerStackData: container_count: int = 0 +@dataclass(slots=True) +class PortainerVolumeData: + """Volume data held by the Portainer coordinator.""" + + volume: DockerVolume + + class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorData]]): """Data Update Coordinator for Portainer.""" @@ -94,6 +104,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD 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] @@ -106,6 +117,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.""" @@ -118,13 +132,13 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD translation_placeholders={"error": repr(err)}, ) from err except PortainerConnectionError as err: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="cannot_connect", translation_placeholders={"error": repr(err)}, ) from err except PortainerTimeoutError as err: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", translation_placeholders={"error": repr(err)}, @@ -168,15 +182,36 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD docker_version, docker_info, docker_system_df, - stacks, + 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), - self.portainer.get_stacks(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 + ) + ) + + stacks = [ + stack + for result in await asyncio.gather(*stack_requests) + for stack in result + ] + prev_endpoint = self.data.get(endpoint.id) if self.data else None container_map: dict[str, PortainerContainerData] = {} stack_map: dict[str, PortainerStackData] = { @@ -184,6 +219,19 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD for stack in stacks } + 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) + # Map containers, started and stopped for container in containers: container_name = self._get_container_name(container.names[0]) @@ -212,18 +260,19 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD else None, ) - # Separately fetch stats for running containers - running_containers = [ + # Separately fetch stats for active containers + active_containers = [ container for container in containers - if container.state == ContainerState.RUNNING + if container.state + in (DockerContainerState.RUNNING, DockerContainerState.PAUSED) ] - if running_containers: + if active_containers: container_stats = dict( zip( ( self._get_container_name(container.names[0]) - for container in running_containers + for container in active_containers ), await asyncio.gather( *( @@ -231,7 +280,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD endpoint_id=endpoint.id, container_id=container.id, ) - for container in running_containers + for container in active_containers ) ), strict=False, @@ -264,6 +313,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD docker_version=docker_version, docker_info=docker_info, docker_system_df=docker_system_df, + volumes=volume_map, stacks=stack_map, ) @@ -310,6 +360,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) 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..04b61aaa3c4 100644 --- a/homeassistant/components/portainer/entity.py +++ b/homeassistant/components/portainer/entity.py @@ -9,10 +9,12 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_NAME, DOMAIN from .coordinator import ( + DockerVolume, PortainerContainerData, PortainerCoordinator, PortainerCoordinatorData, PortainerStackData, + PortainerVolumeData, ) @@ -173,3 +175,56 @@ 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 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}_{self.endpoint_id}_volume_{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 319efef85dc..842cdc16fc2 100644 --- a/homeassistant/components/portainer/icons.json +++ b/homeassistant/components/portainer/icons.json @@ -1,11 +1,20 @@ { "entity": { "button": { + "kill_container": { + "default": "mdi:cog-stop" + }, "pause_container": { "default": "mdi:pause-circle" }, + "recreate_container": { + "default": "mdi:creation" + }, "resume_container": { "default": "mdi:play" + }, + "volumes_prune": { + "default": "mdi:delete-sweep" } }, "sensor": { @@ -86,6 +95,9 @@ }, "volume_disk_usage_total_size": { "default": "mdi:harddisk" + }, + "volume_driver": { + "default": "mdi:docker" } }, "switch": { diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index ecbbd05e4dc..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.33"] + "requirements": ["pyportainer==1.0.38"] } diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py index 503c6e1093e..e079307a8ec 100644 --- a/homeassistant/components/portainer/sensor.py +++ b/homeassistant/components/portainer/sensor.py @@ -1,10 +1,10 @@ """Sensor platform for Portainer integration.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass +from pyportainer import StackType + from homeassistant.components.sensor import ( EntityCategory, SensorDeviceClass, @@ -17,17 +17,18 @@ from homeassistant.const import PERCENTAGE, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import StackType from .coordinator import ( PortainerConfigEntry, PortainerContainerData, PortainerStackData, + PortainerVolumeData, ) from .entity import ( PortainerContainerEntity, PortainerCoordinatorData, PortainerEndpointEntity, PortainerStackEntity, + PortainerVolumeEntity, ) PARALLEL_UPDATES = 0 @@ -54,6 +55,13 @@ class PortainerStackSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[PortainerStackData], 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", @@ -286,7 +294,6 @@ ENDPOINT_SENSORS: tuple[PortainerEndpointSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ), ) - STACK_SENSORS: tuple[PortainerStackSensorEntityDescription, ...] = ( PortainerStackSensorEntityDescription( key="stack_type", @@ -312,6 +319,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( @@ -365,9 +391,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( [ @@ -390,6 +432,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): @@ -424,3 +473,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/strings.json b/homeassistant/components/portainer/strings.json index e50d48849db..abb215e1455 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -66,14 +66,23 @@ "images_prune": { "name": "Prune unused images" }, + "kill_container": { + "name": "Kill container" + }, "pause_container": { "name": "Pause container" }, + "recreate_container": { + "name": "Recreate container" + }, "restart_container": { "name": "Restart container" }, "resume_container": { "name": "Resume container" + }, + "volumes_prune": { + "name": "Prune unused volumes" } }, "sensor": { @@ -168,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": { @@ -221,5 +236,10 @@ }, "name": "Prune unused images" } + }, + "system_health": { + "info": { + "can_reach_server": "Reach Portainer server" + } } } diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py index 478c991f513..ca9b7209326 100644 --- a/homeassistant/components/portainer/switch.py +++ b/homeassistant/components/portainer/switch.py @@ -1,12 +1,10 @@ """Switch platform for Portainer containers.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any -from pyportainer import Portainer +from pyportainer import DockerContainerState, Portainer, StackStatus from pyportainer.exceptions import ( PortainerAuthenticationError, PortainerConnectionError, @@ -23,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PortainerConfigEntry -from .const import DOMAIN, StackStatus +from .const import DOMAIN from .coordinator import ( PortainerContainerData, PortainerCoordinator, @@ -88,7 +86,10 @@ CONTAINER_SWITCHES: tuple[PortainerSwitchEntityDescription, ...] = ( key="container", translation_key="container", device_class=SwitchDeviceClass.SWITCH, - is_on_fn=lambda data: data.container.state == "running", + is_on_fn=lambda data: ( + data.container.state + in (DockerContainerState.RUNNING, DockerContainerState.PAUSED) + ), turn_on_fn=lambda portainer: portainer.start_container, turn_off_fn=lambda portainer: portainer.stop_container, ), 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 f4369b0e225..18d724d67cd 100644 --- a/homeassistant/components/power/strings.json +++ b/homeassistant/components/power/strings.json @@ -1,53 +1,35 @@ { "common": { - "condition_behavior_description": "How the power value should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "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": { "is_value": { "description": "Tests the power value of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::power::common::condition_behavior_description%]", "name": "[%key:component::power::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::power::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::power::common::condition_threshold_description%]", "name": "[%key:component::power::common::condition_threshold_name%]" } }, "name": "Power value" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Power", "triggers": { "changed": { "description": "Triggers after one or more power values change.", "fields": { "threshold": { - "description": "[%key:component::power::common::trigger_threshold_changed_description%]", "name": "[%key:component::power::common::trigger_threshold_name%]" } }, @@ -57,11 +39,12 @@ "description": "Triggers after one or more power values cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::power::common::trigger_behavior_description%]", "name": "[%key:component::power::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::power::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::power::common::trigger_threshold_crossed_description%]", "name": "[%key:component::power::common::trigger_threshold_name%]" } }, diff --git a/homeassistant/components/power/trigger.py b/homeassistant/components/power/trigger.py index b43dc072f7a..eb5bf97428f 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 diff --git a/homeassistant/components/power/triggers.yaml b/homeassistant/components/power/triggers.yaml index 22dac96db36..95d8cc19ee5 100644 --- a/homeassistant/components/power/triggers.yaml +++ b/homeassistant/components/power/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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..6a1baa038bc 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 ( diff --git a/homeassistant/components/powerfox/diagnostics.py b/homeassistant/components/powerfox/diagnostics.py index 18d68c68ae6..08ed37cf41b 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 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..766d06ce5e3 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 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..09b259a3690 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 diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py index b8df599feb6..37ce232c876 100644 --- a/homeassistant/components/powerwall/sensor.py +++ b/homeassistant/components/powerwall/sensor.py @@ -1,7 +1,5 @@ """Support for powerwall sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from operator import attrgetter, methodcaller 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/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..27161a14809 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 @@ -242,6 +240,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=hass-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..23f9e295c46 100644 --- a/homeassistant/components/private_ble_device/device_tracker.py +++ b/homeassistant/components/private_ble_device/device_tracker.py @@ -1,7 +1,5 @@ """Tracking for bluetooth low energy devices.""" -from __future__ import annotations - from collections.abc import Mapping import logging diff --git a/homeassistant/components/private_ble_device/entity.py b/homeassistant/components/private_ble_device/entity.py index 2c574805c53..e8b2370add2 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 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..a4e23edd8d1 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -85,6 +85,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=hass-use-runtime-data domain_data = hass.data[DOMAIN] = {} async def _async_run_profile(call: ServiceCall) -> None: diff --git a/homeassistant/components/profiler/strings.json b/homeassistant/components/profiler/strings.json index b95f6d83738..8a7c73b368d 100644 --- a/homeassistant/components/profiler/strings.json +++ b/homeassistant/components/profiler/strings.json @@ -8,7 +8,7 @@ }, "services": { "dump_log_objects": { - "description": "Dumps the repr of all matching objects to the log.", + "description": "Lets the Profiler dump the repr of all matching objects to the log.", "fields": { "type": { "description": "The type of objects to dump to the log.", @@ -18,37 +18,37 @@ "name": "Dump log objects" }, "dump_sockets": { - "description": "Logs information about all currently used sockets.", + "description": "Lets the Profiler log information about all currently used sockets.", "name": "Dump used sockets" }, "log_current_tasks": { - "description": "Logs all the current asyncio tasks.", + "description": "Lets the Profiler log all the current asyncio tasks.", "name": "Log current asyncio tasks" }, "log_event_loop_scheduled": { - "description": "Logs what is scheduled in the event loop.", + "description": "Lets the Profiler log what is scheduled in the event loop.", "name": "Log event loop scheduled" }, "log_thread_frames": { - "description": "Logs the current frames for all threads.", + "description": "Lets the Profiler log the current frames for all threads.", "name": "Log thread frames" }, "lru_stats": { - "description": "Logs the stats of all lru caches.", + "description": "Lets the Profiler log the stats of all LRU caches.", "name": "Log LRU stats" }, "memory": { - "description": "Starts the Memory Profiler.", + "description": "Lets the Profiler create a memory profile for a specified number of seconds.", "fields": { "seconds": { - "description": "The number of seconds to run the memory profiler.", - "name": "Seconds" + "description": "[%key:component::profiler::services::start::fields::seconds::description%]", + "name": "[%key:component::profiler::services::start::fields::seconds::name%]" } }, - "name": "Memory" + "name": "Create memory profile" }, "set_asyncio_debug": { - "description": "Enable or disable asyncio debug.", + "description": "Lets the Profiler enable or disable asyncio debug.", "fields": { "enabled": { "description": "Whether to enable or disable asyncio debug.", @@ -58,17 +58,17 @@ "name": "Set asyncio debug" }, "start": { - "description": "Starts the Profiler.", + "description": "Lets the Profiler create a system profile for a specified number of seconds.", "fields": { "seconds": { - "description": "The number of seconds to run the profiler.", + "description": "The number of seconds to run the Profiler.", "name": "Seconds" } }, - "name": "[%key:common::action::start%]" + "name": "Create system profile" }, "start_log_object_sources": { - "description": "Starts logging sources of new objects in memory.", + "description": "Starts the Profiler logging sources of new objects in memory.", "fields": { "max_objects": { "description": "The maximum number of objects to log.", @@ -82,7 +82,7 @@ "name": "Start logging object sources" }, "start_log_objects": { - "description": "Starts logging growth of objects in memory.", + "description": "Starts the Profiler logging growth of objects in memory.", "fields": { "scan_interval": { "description": "The number of seconds between logging objects.", @@ -92,11 +92,11 @@ "name": "Start logging objects" }, "stop_log_object_sources": { - "description": "Stops logging sources of new objects in memory.", + "description": "Stops the Profiler logging sources of new objects in memory.", "name": "Stop logging object sources" }, "stop_log_objects": { - "description": "Stops logging growth of objects in memory.", + "description": "Stops the Profiler logging growth of objects in memory.", "name": "Stop logging objects" } } diff --git a/homeassistant/components/progettihwsw/__init__.py b/homeassistant/components/progettihwsw/__init__.py index 4d090f4d0c1..32dbb5bd41d 100644 --- a/homeassistant/components/progettihwsw/__init__.py +++ b/homeassistant/components/progettihwsw/__init__.py @@ -8,33 +8,32 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS = [Platform.BINARY_SENSOR, Platform.SWITCH] +type ProgettiHWSWConfigEntry = ConfigEntry[ProgettiHWSWAPI] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: ProgettiHWSWConfigEntry +) -> bool: """Set up ProgettiHWSW Automation from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = ProgettiHWSWAPI( - f"{entry.data['host']}:{entry.data['port']}" - ) + api = ProgettiHWSWAPI(f"{entry.data['host']}:{entry.data['port']}") # Check board validation again to load new values to API. - await hass.data[DOMAIN][entry.entry_id].check_board() + await api.check_board() + + entry.runtime_data = api 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: ProgettiHWSWConfigEntry +) -> 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 setup_input(api: ProgettiHWSWAPI, input_number: int) -> Input: diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index aeec792cff1..26643065994 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -7,7 +7,6 @@ import logging from ProgettiHWSW.input import Input 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 homeassistant.helpers.update_coordinator import ( @@ -15,19 +14,19 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from . import setup_input -from .const import DEFAULT_POLLING_INTERVAL_SEC, DOMAIN +from . import ProgettiHWSWConfigEntry, setup_input +from .const import DEFAULT_POLLING_INTERVAL_SEC -_LOGGER = logging.getLogger(DOMAIN) +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ProgettiHWSWConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensors from a config entry.""" - board_api = hass.data[DOMAIN][config_entry.entry_id] + board_api = config_entry.runtime_data input_count = config_entry.data["input_count"] async def async_update_data(): diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index b2f00d52439..06a8f6d0d66 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -8,7 +8,6 @@ from typing import Any from ProgettiHWSW.relay import Relay 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 homeassistant.helpers.update_coordinator import ( @@ -16,19 +15,19 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from . import setup_switch -from .const import DEFAULT_POLLING_INTERVAL_SEC, DOMAIN +from . import ProgettiHWSWConfigEntry, setup_switch +from .const import DEFAULT_POLLING_INTERVAL_SEC -_LOGGER = logging.getLogger(DOMAIN) +_LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ProgettiHWSWConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switches from a config entry.""" - board_api = hass.data[DOMAIN][config_entry.entry_id] + board_api = config_entry.runtime_data relay_count = config_entry.data["relay_count"] async def async_update_data(): 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..3132123d6d0 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 diff --git a/homeassistant/components/prosegur/__init__.py b/homeassistant/components/prosegur/__init__.py index bf2aad451df..1fb89f3b370 100644 --- a/homeassistant/components/prosegur/__init__.py +++ b/homeassistant/components/prosegur/__init__.py @@ -10,25 +10,24 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import DOMAIN - PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.CAMERA] +type ProsegurConfigEntry = ConfigEntry[Auth] + _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ProsegurConfigEntry) -> bool: """Set up Prosegur Alarm from a config entry.""" try: session = aiohttp_client.async_get_clientsession(hass) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = Auth( + auth = Auth( session, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], entry.data[CONF_COUNTRY], ) - await hass.data[DOMAIN][entry.entry_id].login() + await auth.login() except ConnectionRefusedError as error: _LOGGER.error("Configured credential are invalid, %s", error) @@ -39,15 +38,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Could not connect with Prosegur backend: %s", error) raise ConfigEntryNotReady from error + entry.runtime_data = auth + 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: ProsegurConfigEntry) -> 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/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index 1f0f89c5f04..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 @@ -12,12 +10,12 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -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 +from . import ProsegurConfigEntry +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -31,12 +29,12 @@ STATE_MAPPING = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ProsegurConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Prosegur alarm control panel platform.""" async_add_entities( - [ProsegurAlarm(entry.data["contract"], hass.data[DOMAIN][entry.entry_id])], + [ProsegurAlarm(entry.data["contract"], entry.runtime_data)], update_before_add=True, ) diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py index 3e1c91713e1..108792546e3 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 @@ -9,7 +7,6 @@ from pyprosegur.exceptions import ProsegurException from pyprosegur.installation import Camera as InstallationCamera, Installation from homeassistant.components.camera import Camera -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( @@ -17,15 +14,15 @@ from homeassistant.helpers.entity_platform import ( async_get_current_platform, ) -from . import DOMAIN -from .const import SERVICE_REQUEST_IMAGE +from . import ProsegurConfigEntry +from .const import DOMAIN, SERVICE_REQUEST_IMAGE _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: ProsegurConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Prosegur camera platform.""" @@ -38,12 +35,12 @@ async def async_setup_entry( ) _installation = await Installation.retrieve( - hass.data[DOMAIN][entry.entry_id], entry.data["contract"] + entry.runtime_data, entry.data["contract"] ) async_add_entities( [ - ProsegurCamera(_installation, camera, hass.data[DOMAIN][entry.entry_id]) + ProsegurCamera(_installation, camera, entry.runtime_data) for camera in _installation.cameras ], update_before_add=True, diff --git a/homeassistant/components/prosegur/diagnostics.py b/homeassistant/components/prosegur/diagnostics.py index ec13f5511a4..4639973d4e8 100644 --- a/homeassistant/components/prosegur/diagnostics.py +++ b/homeassistant/components/prosegur/diagnostics.py @@ -1,30 +1,28 @@ """Diagnostics support for Prosegur.""" -from __future__ import annotations - from typing import Any from pyprosegur.installation import Installation from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import CONF_CONTRACT, DOMAIN +from . import ProsegurConfigEntry +from .const import CONF_CONTRACT TO_REDACT = {"description", "latitude", "longitude", "contractId", "address"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ProsegurConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" installation = await Installation.retrieve( - hass.data[DOMAIN][entry.entry_id], entry.data[CONF_CONTRACT] + entry.runtime_data, entry.data[CONF_CONTRACT] ) - activity = await installation.activity(hass.data[DOMAIN][entry.entry_id]) + activity = await installation.activity(entry.runtime_data) return { "installation": async_redact_data(installation.data, TO_REDACT), diff --git a/homeassistant/components/prowl/config_flow.py b/homeassistant/components/prowl/config_flow.py index cea3ee6e106..877a3a8ea90 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 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/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..de98e3d1282 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 ( diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py index 6512b1761cd..ab98a0645f3 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 @@ -189,6 +187,14 @@ 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. 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 833600d8ebd..f35a4b159f7 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 @@ -28,14 +26,17 @@ from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity from .helpers import is_granted +NO_PERM_VM_LXC_POWER = "no_permission_vm_lxc_power" + @dataclass(frozen=True, kw_only=True) class ProxmoxNodeButtonNodeEntityDescription(ButtonEntityDescription): """Class to hold Proxmox node button description.""" press_action: Callable[[ProxmoxCoordinator, str], None] - permission: ProxmoxPermission = ProxmoxPermission.POWER + permission: ProxmoxPermission = ProxmoxPermission.SYSPOWER permission_raise: str = "no_permission_node_power" + permission_target: str = "nodes" @dataclass(frozen=True, kw_only=True) @@ -44,7 +45,8 @@ class ProxmoxVMButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = "no_permission_vm_lxc_power" + permission_raise: str = NO_PERM_VM_LXC_POWER + permission_target: str = "vms" @dataclass(frozen=True, kw_only=True) @@ -53,7 +55,8 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): press_action: Callable[[ProxmoxCoordinator, str, int], None] permission: ProxmoxPermission = ProxmoxPermission.POWER - permission_raise: str = "no_permission_vm_lxc_power" + permission_raise: str = NO_PERM_VM_LXC_POWER + permission_target: str = "vms" NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( @@ -76,6 +79,9 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( ProxmoxNodeButtonNodeEntityDescription( key="start_all", translation_key="start_all", + permission=ProxmoxPermission.POWER, + permission_raise=NO_PERM_VM_LXC_POWER, + permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node ).startall.post(), @@ -84,6 +90,9 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( ProxmoxNodeButtonNodeEntityDescription( key="stop_all", translation_key="stop_all", + permission=ProxmoxPermission.POWER, + permission_raise=NO_PERM_VM_LXC_POWER, + permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node ).stopall.post(), @@ -92,6 +101,9 @@ NODE_BUTTONS: tuple[ProxmoxNodeButtonNodeEntityDescription, ...] = ( ProxmoxNodeButtonNodeEntityDescription( key="suspend_all", translation_key="suspend_all", + permission=ProxmoxPermission.POWER, + permission_raise=NO_PERM_VM_LXC_POWER, + permission_target="vms", press_action=lambda coordinator, node: coordinator.proxmox.nodes( node ).suspendall.post(), @@ -132,6 +144,14 @@ VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = ( ), entity_category=EntityCategory.CONFIG, ), + ProxmoxVMButtonEntityDescription( + key="resume", + translation_key="resume", + press_action=lambda coordinator, node, vmid: ( + coordinator.proxmox.nodes(node).qemu(vmid).status.resume.post() + ), + entity_category=EntityCategory.CONFIG, + ), ProxmoxVMButtonEntityDescription( key="reset", translation_key="reset", @@ -327,7 +347,7 @@ class ProxmoxNodeButtonEntity(ProxmoxNodeEntity, ProxmoxBaseButton): node_id = self._node_data.node["node"] if not is_granted( self.coordinator.permissions, - p_type="nodes", + p_type=self.entity_description.permission_target, p_id=node_id, permission=self.entity_description.permission, ): @@ -352,7 +372,7 @@ class ProxmoxVMButtonEntity(ProxmoxVMEntity, ProxmoxBaseButton): vmid = self.vm_data["vmid"] if not is_granted( self.coordinator.permissions, - p_type="vms", + p_type=self.entity_description.permission_target, p_id=vmid, permission=self.entity_description.permission, ): @@ -379,7 +399,7 @@ class ProxmoxContainerButtonEntity(ProxmoxContainerEntity, ProxmoxBaseButton): # Container power actions fall under vms if not is_granted( self.coordinator.permissions, - p_type="vms", + p_type=self.entity_description.permission_target, p_id=vmid, permission=self.entity_description.permission, ): diff --git a/homeassistant/components/proxmoxve/config_flow.py b/homeassistant/components/proxmoxve/config_flow.py index 7845f5405b9..26b591f23c9 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 @@ -45,6 +43,7 @@ from .const import ( DEFAULT_REALM, DEFAULT_VERIFY_SSL, DOMAIN, + NODE_ONLINE, ) _LOGGER = logging.getLogger(__name__) @@ -97,6 +96,19 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]: verify_ssl=data.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), **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 +117,27 @@ 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 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 +320,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 +398,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 4cf821446c1..cd7bd7db54b 100644 --- a/homeassistant/components/proxmoxve/const.py +++ b/homeassistant/components/proxmoxve/const.py @@ -21,7 +21,7 @@ VM_CONTAINER_RUNNING = "running" STORAGE_ACTIVE = 1 STORAGE_SHARED = 1 STORAGE_ENABLED = 1 -STATUS_OK = "ok" +STATUS_OK = "OK" AUTH_PAM = "pam" AUTH_PVE = "pve" @@ -41,3 +41,4 @@ class ProxmoxPermission(StrEnum): POWER = "VM.PowerMgmt" SNAPSHOT = "VM.Snapshot" + SYSPOWER = "Sys.PowerMgmt" diff --git a/homeassistant/components/proxmoxve/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index 6231a989a21..b901475268e 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 @@ -22,11 +20,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryError, - ConfigEntryNotReady, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .common import sanitize_config_entry @@ -47,6 +41,16 @@ DEFAULT_UPDATE_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) +@dataclass(slots=True, kw_only=True) +class NodeResources: + """Raw API resources fetched for a single Proxmox node.""" + + vms: list[dict[str, Any]] + containers: list[dict[str, Any]] + storages: list[dict[str, Any]] + backups: list[dict[str, Any]] + + @dataclass(slots=True, kw_only=True) class ProxmoxNodeData: """All resources for a single Proxmox node.""" @@ -112,13 +116,13 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): translation_placeholders={"error": repr(err)}, ) from err except ConnectTimeout as err: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="timeout_connect", translation_placeholders={"error": repr(err)}, ) from err except ProxmoxServerError as err: - raise ConfigEntryNotReady( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="api_error_details", translation_placeholders={"error": repr(err)}, @@ -144,9 +148,7 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): """Fetch data from Proxmox VE API.""" try: - nodes, vms_containers = await self.hass.async_add_executor_job( - self._fetch_all_nodes - ) + node_pairs = await self.hass.async_add_executor_job(self._fetch_all_nodes) except AuthenticationError as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -178,17 +180,16 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): ) from err data: dict[str, ProxmoxNodeData] = {} - for node, (vms, containers, storages, backups) in zip( - nodes, vms_containers, strict=True - ): + for node, resources in node_pairs: data[node[CONF_NODE]] = ProxmoxNodeData( node=node, - vms={int(vm["vmid"]): vm for vm in vms}, + vms={int(vm["vmid"]): vm for vm in resources.vms}, containers={ - int(container["vmid"]): container for container in containers + int(container["vmid"]): container + for container in resources.containers }, - storages={s["storage"]: s for s in storages}, - backups=backups, + storages={s["storage"]: s for s in resources.storages}, + backups=resources.backups, ) self._async_add_remove_nodes(data) @@ -233,40 +234,22 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): raise ProxmoxNodesNotFoundError from err raise ProxmoxServerError from err - def _fetch_all_nodes( - self, - ) -> tuple[ - list[dict[str, Any]], - list[ - tuple[ - list[dict[str, Any]], - list[dict[str, Any]], - list[dict[str, Any]], - list[dict[str, Any]], - ] - ], - ]: - """Fetch all nodes, and then proceed to the VMs, containers, storages, and backups.""" + def _fetch_all_nodes(self) -> list[tuple[dict[str, Any], NodeResources]]: + """Fetch all nodes with their VMs, containers, storages, and backups.""" nodes = self.proxmox.nodes.get() or [] - node_data = [self._get_node_data(node) for node in nodes] - return nodes, node_data + return [(node, self._get_node_data(node)) for node in nodes] def _get_node_data( self, node: dict[str, Any], - ) -> tuple[ - list[dict[str, Any]], - list[dict[str, Any]], - list[dict[str, Any]], - list[dict[str, Any]], - ]: + ) -> NodeResources: """Get vms, containers, storages, and backups for a node.""" if node.get("status") != NODE_ONLINE: _LOGGER.debug( "Node %s is offline, skipping VM/container/storage fetch", node[CONF_NODE], ) - return [], [], [], [] + return NodeResources(vms=[], containers=[], storages=[], backups=[]) vms = self.proxmox.nodes(node[CONF_NODE]).qemu.get() or [] containers = self.proxmox.nodes(node[CONF_NODE]).lxc.get() or [] @@ -276,7 +259,9 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]): or [] ) - return vms, containers, storages, backups + return NodeResources( + vms=vms, containers=containers, storages=storages, backups=backups + ) def _async_add_remove_nodes(self, data: dict[str, ProxmoxNodeData]) -> None: """Add new nodes/VMs/containers, track removals.""" 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..12cde5c4cd2 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 diff --git a/homeassistant/components/proxmoxve/icons.json b/homeassistant/components/proxmoxve/icons.json index 9f1a83a98cc..7c60fef838f 100644 --- a/homeassistant/components/proxmoxve/icons.json +++ b/homeassistant/components/proxmoxve/icons.json @@ -27,6 +27,9 @@ "reset": { "default": "mdi:restart" }, + "resume": { + "default": "mdi:play" + }, "shutdown": { "default": "mdi:power" }, diff --git a/homeassistant/components/proxmoxve/sensor.py b/homeassistant/components/proxmoxve/sensor.py index cb7977745ce..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 @@ -426,7 +424,11 @@ STORAGE_SENSORS: tuple[ProxmoxStorageSensorEntityDescription, ...] = ( ProxmoxStorageSensorEntityDescription( key="storage_used_percentage", translation_key="storage_used_percentage", - value_fn=lambda data: round(data["used_fraction"] * 100, 1), + value_fn=lambda data: ( + round(value * 100, 1) + if (value := data.get("used_fraction")) is not None + else None + ), native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/proxmoxve/strings.json b/homeassistant/components/proxmoxve/strings.json index 12ee765d9f2..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": { @@ -140,8 +142,11 @@ "reset": { "name": "Reset" }, + "resume_all": { + "name": "Resume" + }, "shutdown": { - "name": "Shutdown" + "name": "Shut down" }, "snapshot_create": { "name": "Create snapshot" @@ -313,7 +318,7 @@ "message": "No active nodes were found on the Proxmox VE server." }, "no_permission_node_power": { - "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'VM.PowerMgmt' permission and try again." + "message": "The configured Proxmox VE user does not have permission to manage the power state of nodes. Please grant the user the 'Sys.PowerMgmt' permission and try again." }, "no_permission_snapshot": { "message": "The configured Proxmox VE user does not have permission to create snapshots of VMs and containers. Please grant the user the 'VM.Snapshot' permission and try again." @@ -321,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 4bb7dee411d..b4deff5cb43 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 @@ -24,8 +22,10 @@ from .coordinator import ( InfoUpdateCoordinator, JobUpdateCoordinator, LegacyStatusCoordinator, + PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator, StatusCoordinator, + VersionUpdateCoordinator, ) PLATFORMS: list[Platform] = [ @@ -36,7 +36,7 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PrusaLinkConfigEntry) -> bool: """Set up PrusaLink from a config entry.""" if entry.version == 1 and entry.minor_version < 2: raise ConfigEntryError("Please upgrade your printer's firmware.") @@ -53,11 +53,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "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() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + entry.runtime_data = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -120,9 +121,6 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: PrusaLinkConfigEntry) -> 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/prusalink/binary_sensor.py b/homeassistant/components/prusalink/binary_sensor.py index 56be36c3e9d..d85558ae8a4 100644 --- a/homeassistant/components/prusalink/binary_sensor.py +++ b/homeassistant/components/prusalink/binary_sensor.py @@ -1,7 +1,5 @@ """PrusaLink binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from typing import Generic, TypeVar @@ -13,12 +11,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 PrusaLinkUpdateCoordinator +from .coordinator import PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator from .entity import PrusaLinkEntity T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo, PrinterInfo) @@ -56,13 +52,11 @@ BINARY_SENSORS: dict[str, tuple[PrusaLinkBinarySensorEntityDescription, ...]] = async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PrusaLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink sensor based on a config entry.""" - coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinators = entry.runtime_data entities: list[PrusaLinkEntity] = [] for coordinator_type, binary_sensors in BINARY_SENSORS.items(): diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py index 59a63d874ee..591372d4f8a 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -1,7 +1,5 @@ """PrusaLink sensors.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import Any, Generic, TypeVar, cast @@ -10,13 +8,11 @@ from pyprusalink import JobInfo, LegacyPrinterStatus, PrinterStatus, PrusaLink from pyprusalink.types import Conflict, PrinterState from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -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 PrusaLinkUpdateCoordinator +from .coordinator import PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator from .entity import PrusaLinkEntity T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) @@ -71,13 +67,11 @@ BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PrusaLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink buttons based on a config entry.""" - coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinators = entry.runtime_data entities: list[PrusaLinkEntity] = [] @@ -124,9 +118,7 @@ class PrusaLinkButtonEntity(PrusaLinkEntity, ButtonEntity): "Action conflicts with current printer state" ) from err - coordinators: dict[str, PrusaLinkUpdateCoordinator] = self.hass.data[DOMAIN][ - self.coordinator.config_entry.entry_id - ] + coordinators = self.coordinator.config_entry.runtime_data for coordinator in coordinators.values(): coordinator.expect_change() diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py index 6aac03ca179..56a41c80376 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -1,26 +1,22 @@ """Camera entity for PrusaLink.""" -from __future__ import annotations - from pyprusalink.types import PrinterState from homeassistant.components.camera import Camera -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 JobUpdateCoordinator +from .coordinator import PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator from .entity import PrusaLinkEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PrusaLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink camera.""" - coordinator: JobUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["job"] + coordinator = entry.runtime_data["job"] async_add_entities([PrusaLinkJobPreviewEntity(coordinator)]) @@ -31,7 +27,7 @@ class PrusaLinkJobPreviewEntity(PrusaLinkEntity, Camera): last_image: bytes _attr_translation_key = "job_preview" - def __init__(self, coordinator: JobUpdateCoordinator) -> None: + def __init__(self, coordinator: PrusaLinkUpdateCoordinator) -> None: """Initialize a PrusaLink camera entity.""" super().__init__(coordinator) Camera.__init__(self) diff --git a/homeassistant/components/prusalink/config_flow.py b/homeassistant/components/prusalink/config_flow.py index 6fa72d6a5fd..29e762a823d 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 diff --git a/homeassistant/components/prusalink/coordinator.py b/homeassistant/components/prusalink/coordinator.py index 8d994fa728a..9f97a39c7e6 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,17 +31,20 @@ _LOGGER = logging.getLogger(__name__) # rapidly-changing metrics. _MINIMUM_REFRESH_INTERVAL = 1.0 -T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) +T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo, PrinterInfo, VersionInfo) + + +type PrusaLinkConfigEntry = ConfigEntry[dict[str, PrusaLinkUpdateCoordinator]] class PrusaLinkUpdateCoordinator(DataUpdateCoordinator[T], ABC): """Update coordinator for the printer.""" - config_entry: ConfigEntry + config_entry: PrusaLinkConfigEntry expect_change_until = 0.0 def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: PrusaLink + self, hass: HomeAssistant, config_entry: PrusaLinkConfigEntry, api: PrusaLink ) -> None: """Initialize the update coordinator.""" self.api = api @@ -121,3 +123,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..b24f6ed9f8c 100644 --- a/homeassistant/components/prusalink/entity.py +++ b/homeassistant/components/prusalink/entity.py @@ -1,7 +1,5 @@ """The PrusaLink integration.""" -from __future__ import annotations - from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -17,9 +15,14 @@ class PrusaLinkEntity(CoordinatorEntity[PrusaLinkUpdateCoordinator]): @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, ) diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index cf4818e111e..d39307137d9 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -1,7 +1,5 @@ """PrusaLink sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta @@ -16,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, REVOLUTIONS_PER_MINUTE, @@ -29,8 +26,7 @@ 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 PrusaLinkUpdateCoordinator +from .coordinator import PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator from .entity import PrusaLinkEntity T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo, PrinterInfo) @@ -204,13 +200,11 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PrusaLinkConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up PrusaLink sensor based on a config entry.""" - coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinators = entry.runtime_data entities: list[PrusaLinkEntity] = [] 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/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..4b0fd67ac66 --- /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": "[%key:common::config_flow::error::cannot_connect%]" + }, + "invalid_access_token": { + "message": "[%key:common::config_flow::error::invalid_access_token%]" + } + } +} 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..665f034f4f1 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 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..2126c474a94 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 diff --git a/homeassistant/components/pushbullet/__init__.py b/homeassistant/components/pushbullet/__init__.py index e5892afc926..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 @@ -21,6 +19,8 @@ from homeassistant.helpers.typing import ConfigType from .api import PushBulletNotificationProvider from .const import DATA_HASS_CONFIG, DOMAIN +type PushbulletConfigEntry = ConfigEntry[PushBulletNotificationProvider] + PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -35,7 +35,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: PushbulletConfigEntry) -> bool: """Set up pushbullet from a config entry.""" try: @@ -49,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from err pb_provider = PushBulletNotificationProvider(hass, pushbullet) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = pb_provider + entry.runtime_data = pb_provider def start_listener(event: Event) -> None: """Start the listener thread.""" @@ -72,11 +72,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: PushbulletConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN].pop( - entry.entry_id - ) - await hass.async_add_executor_job(pb_provider.close) + await hass.async_add_executor_job(entry.runtime_data.close) return unload_ok 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..3d929bbe83a 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 diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index f2e70695b27..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 @@ -22,8 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .api import PushBulletNotificationProvider -from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL, DOMAIN +from .const import ATTR_FILE, ATTR_FILE_URL, ATTR_URL _LOGGER = logging.getLogger(__name__) @@ -36,10 +33,10 @@ async def async_get_service( """Get the Pushbullet notification service.""" if TYPE_CHECKING: assert discovery_info is not None - pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN][ - discovery_info["entry_id"] - ] - return PushBulletNotificationService(hass, pb_provider.pushbullet) + entry = hass.config_entries.async_get_entry(discovery_info["entry_id"]) + if TYPE_CHECKING: + assert entry is not None + return PushBulletNotificationService(hass, entry.runtime_data.pushbullet) class PushBulletNotificationService(BaseNotificationService): diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index 3ab55ecf072..e0f40ba5800 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -1,15 +1,13 @@ """Pushbullet platform for sensor component.""" -from __future__ import annotations - from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, MAX_LENGTH_STATE_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import PushbulletConfigEntry from .api import PushBulletNotificationProvider from .const import DATA_UPDATED, DOMAIN @@ -69,12 +67,12 @@ SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES] async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PushbulletConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Pushbullet sensors from config entry.""" - pb_provider: PushBulletNotificationProvider = hass.data[DOMAIN][entry.entry_id] + pb_provider = entry.runtime_data entities = [ PushBulletNotificationSensor(entry.data[CONF_NAME], pb_provider, description) 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..352a1c4b6b7 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 diff --git a/homeassistant/components/pushover/notify.py b/homeassistant/components/pushover/notify.py index 62c14b4dae8..3767ff71890 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=hass-use-runtime-data pushover_api: PushoverAPI = hass.data[DOMAIN][discovery_info["entry_id"]] return PushoverNotificationService( hass, pushover_api, discovery_info[CONF_USER_KEY] diff --git a/homeassistant/components/pushsafer/__init__.py b/homeassistant/components/pushsafer/__init__.py index 81dfc7e15fd..abb0c1bad61 100644 --- a/homeassistant/components/pushsafer/__init__.py +++ b/homeassistant/components/pushsafer/__init__.py @@ -1 +1 @@ -"""The pushsafer component.""" +"""The Pushsafer integration.""" 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 7dc02a07d1c..84520a5c926 100644 --- a/homeassistant/components/pvoutput/__init__.py +++ b/homeassistant/components/pvoutput/__init__.py @@ -1,27 +1,22 @@ """The PVOutput integration.""" -from __future__ import annotations - -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN, PLATFORMS -from .coordinator import PVOutputDataUpdateCoordinator +from .const import PLATFORMS +from .coordinator import PvOutputConfigEntry, PVOutputDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PvOutputConfigEntry) -> bool: """Set up PVOutput from a config entry.""" coordinator = PVOutputDataUpdateCoordinator(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: PvOutputConfigEntry) -> bool: """Unload PVOutput 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/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 ce3642421bf..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 @@ -13,13 +18,15 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_SYSTEM_ID, DOMAIN, LOGGER, SCAN_INTERVAL +type PvOutputConfigEntry = ConfigEntry[PVOutputDataUpdateCoordinator] + class PVOutputDataUpdateCoordinator(DataUpdateCoordinator[Status]): """The PVOutput Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: PvOutputConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: PvOutputConfigEntry) -> None: """Initialize the PVOutput coordinator.""" self.pvoutput = PVOutput( api_key=entry.data[CONF_API_KEY], @@ -35,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 3b9007b77b4..5d046c17f32 100644 --- a/homeassistant/components/pvoutput/diagnostics.py +++ b/homeassistant/components/pvoutput/diagnostics.py @@ -1,19 +1,14 @@ """Diagnostics support for PVOutput.""" -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 PVOutputDataUpdateCoordinator +from .coordinator import PvOutputConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: PvOutputConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: PVOutputDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - return coordinator.data.to_dict() + return entry.runtime_data.data.to_dict() 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 b4ed3f93945..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 @@ -13,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfElectricPotential, UnitOfEnergy, @@ -26,7 +23,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_SYSTEM_ID, DOMAIN -from .coordinator import PVOutputDataUpdateCoordinator +from .coordinator import PvOutputConfigEntry, PVOutputDataUpdateCoordinator + +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) @@ -97,11 +96,11 @@ SENSORS: tuple[PVOutputSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PvOutputConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a PVOutput sensors based on a config entry.""" - coordinator: PVOutputDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data system = await coordinator.pvoutput.system() async_add_entities( 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..5cb8daff428 100644 --- a/homeassistant/components/pvpc_hourly_pricing/config_flow.py +++ b/homeassistant/components/pvpc_hourly_pricing/config_flow.py @@ -1,7 +1,5 @@ """Config flow for pvpc_hourly_pricing.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any diff --git a/homeassistant/components/pvpc_hourly_pricing/manifest.json b/homeassistant/components/pvpc_hourly_pricing/manifest.json index c29cd52cf96..18287a2d5e9 100644 --- a/homeassistant/components/pvpc_hourly_pricing/manifest.json +++ b/homeassistant/components/pvpc_hourly_pricing/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiopvpc"], - "requirements": ["aiopvpc==4.2.2"] + "requirements": ["aiopvpc==4.3.1"] } diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py index c49756290ab..e6ed7fe81f0 100644 --- a/homeassistant/components/pvpc_hourly_pricing/sensor.py +++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py @@ -1,7 +1,5 @@ """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 diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py index ca7bbb0c1dc..4bde8b3748d 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 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..307d4268ea3 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 diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index a69ba0c67dd..a9d1ed2a328 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -68,7 +68,9 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]): raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="setup_authentication_exception", - translation_placeholders={CONF_USERNAME: self.pyload.username}, + translation_placeholders={ + CONF_USERNAME: self.config_entry.data[CONF_USERNAME] + }, ) from e except CannotConnect as e: raise UpdateFailed( 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/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 513b49d3561..62f671fc5c4 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -5,7 +5,7 @@ from typing import Any from qbittorrentapi import APIConnectionError, Forbidden403Error, LoginFailed -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, CONF_PASSWORD, @@ -27,7 +27,7 @@ from .const import ( STATE_ATTR_TORRENTS, TORRENT_FILTER, ) -from .coordinator import QBittorrentDataCoordinator +from .coordinator import QBittorrentConfigEntry, QBittorrentDataCoordinator from .helpers import format_torrents, setup_client _LOGGER = logging.getLogger(__name__) @@ -68,7 +68,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: translation_placeholders={"device_id": entry_id or ""}, ) - coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][entry_id] + entry: QBittorrentConfigEntry | None = hass.config_entries.async_get_entry( + entry_id + ) + if entry is None or entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_entry_id", + translation_placeholders={"device_id": entry_id}, + ) + coordinator = entry.runtime_data items = await coordinator.get_torrents(service_call.data[TORRENT_FILTER]) info = format_torrents(items) return { @@ -87,10 +96,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) -> dict[str, Any] | None: torrents = {} - for key, value in hass.data[DOMAIN].items(): - coordinator: QBittorrentDataCoordinator = value + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + coordinator: QBittorrentDataCoordinator = entry.runtime_data items = await coordinator.get_torrents(service_call.data[TORRENT_FILTER]) - torrents[key] = format_torrents(items) + torrents[entry.entry_id] = format_torrents(items) return { STATE_ATTR_ALL_TORRENTS: torrents, @@ -106,7 +115,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: QBittorrentConfigEntry +) -> bool: """Set up qBittorrent from a config entry.""" try: @@ -127,19 +138,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b coordinator = QBittorrentDataCoordinator(hass, config_entry, client) 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) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: QBittorrentConfigEntry +) -> bool: """Unload qBittorrent config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ): - del hass.data[DOMAIN][config_entry.entry_id] - if not hass.data[DOMAIN]: - del hass.data[DOMAIN] - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) 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 8fd23fb3b5b..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 @@ -24,14 +22,16 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +type QBittorrentConfigEntry = ConfigEntry[QBittorrentDataCoordinator] + class QBittorrentDataCoordinator(DataUpdateCoordinator[SyncMainDataDictionary]): """Coordinator for updating QBittorrent data.""" - config_entry: ConfigEntry + config_entry: QBittorrentConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, client: Client + self, hass: HomeAssistant, config_entry: QBittorrentConfigEntry, client: Client ) -> None: """Initialize coordinator.""" self.client = client diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index afad29a5b73..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 @@ -13,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -22,7 +19,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, STATE_DOWNLOADING, STATE_SEEDING, STATE_UP_DOWN -from .coordinator import QBittorrentDataCoordinator +from .coordinator import QBittorrentConfigEntry, QBittorrentDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -236,12 +233,12 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: QBittorrentConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up qBittorrent sensor entries.""" - coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( QBittorrentSensor(coordinator, config_entry, description) @@ -258,7 +255,7 @@ class QBittorrentSensor(CoordinatorEntity[QBittorrentDataCoordinator], SensorEnt def __init__( self, coordinator: QBittorrentDataCoordinator, - config_entry: ConfigEntry, + config_entry: QBittorrentConfigEntry, entity_description: QBittorrentSensorEntityDescription, ) -> None: """Initialize the qBittorrent sensor.""" diff --git a/homeassistant/components/qbittorrent/switch.py b/homeassistant/components/qbittorrent/switch.py index dd61f130ca1..4148fd9867f 100644 --- a/homeassistant/components/qbittorrent/switch.py +++ b/homeassistant/components/qbittorrent/switch.py @@ -1,20 +1,17 @@ """Support for monitoring the qBittorrent API.""" -from __future__ import annotations - from collections.abc import Callable 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.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 QBittorrentDataCoordinator +from .coordinator import QBittorrentConfigEntry, QBittorrentDataCoordinator @dataclass(frozen=True, kw_only=True) @@ -42,12 +39,12 @@ SWITCH_TYPES: tuple[QBittorrentSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: QBittorrentConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up qBittorrent switch entries.""" - coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( QBittorrentSwitch(coordinator, config_entry, description) @@ -64,7 +61,7 @@ class QBittorrentSwitch(CoordinatorEntity[QBittorrentDataCoordinator], SwitchEnt def __init__( self, coordinator: QBittorrentDataCoordinator, - config_entry: ConfigEntry, + config_entry: QBittorrentConfigEntry, entity_description: QBittorrentSwitchEntityDescription, ) -> None: """Initialize qBittorrent switch.""" 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/coordinator.py b/homeassistant/components/qbus/coordinator.py index c3fbf4b60bb..795606a250d 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 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..2e43b1d444f 100644 --- a/homeassistant/components/qbus/light.py +++ b/homeassistant/components/qbus/light.py @@ -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..c14a46eae11 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.4.3"] } 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..aed1fa1ded9 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, diff --git a/homeassistant/components/qingping/config_flow.py b/homeassistant/components/qingping/config_flow.py index 990eb5116eb..50ce7b2dae4 100644 --- a/homeassistant/components/qingping/config_flow.py +++ b/homeassistant/components/qingping/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Qingping integration.""" -from __future__ import annotations - from typing import Any from qingping_ble import QingpingBluetoothDeviceData as DeviceData 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/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 82e912a60cd..8ccbf372415 100644 --- a/homeassistant/components/qnap/__init__.py +++ b/homeassistant/components/qnap/__init__.py @@ -1,34 +1,26 @@ """The qnap 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 QnapCoordinator +from .coordinator import QnapConfigEntry, QnapCoordinator PLATFORMS: list[Platform] = [ Platform.SENSOR, ] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: QnapConfigEntry) -> bool: """Set the config entry up.""" - hass.data.setdefault(DOMAIN, {}) coordinator = QnapCoordinator(hass, config_entry) - # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][config_entry.entry_id] = coordinator + config_entry.runtime_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: QnapConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ): - 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/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 8b6cb930b4f..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 @@ -26,6 +24,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +type QnapConfigEntry = ConfigEntry[QnapCoordinator] + UPDATE_INTERVAL = timedelta(minutes=1) _LOGGER = logging.getLogger(__name__) @@ -46,7 +46,9 @@ def suppress_insecure_request_warning(): class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): """Custom coordinator for the qnap integration.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + config_entry: QnapConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: QnapConfigEntry) -> None: """Initialize the qnap coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 381455cb7e1..866c34f8490 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -1,11 +1,8 @@ """Support for QNAP NAS Sensors.""" -from __future__ import annotations - from datetime import timedelta from typing import Any -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -20,14 +17,13 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.device_registry import 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 -from .coordinator import QnapCoordinator +from .coordinator import QnapConfigEntry, QnapCoordinator ATTR_DRIVE = "Drive" ATTR_IP = "IP Address" @@ -247,14 +243,11 @@ SENSOR_KEYS: list[str] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: config_entries.ConfigEntry, + config_entry: QnapConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entry.""" - coordinator = QnapCoordinator(hass, config_entry) - await coordinator.async_refresh() - if not coordinator.last_update_success: - raise PlatformNotReady + coordinator = config_entry.runtime_data uid = config_entry.unique_id assert uid is not None sensors: list[QNAPSensor] = [] diff --git a/homeassistant/components/qnap_qsw/__init__.py b/homeassistant/components/qnap_qsw/__init__.py index f9faca025a5..534179b8b32 100644 --- a/homeassistant/components/qnap_qsw/__init__.py +++ b/homeassistant/components/qnap_qsw/__init__.py @@ -1,19 +1,20 @@ """The QNAP QSW integration.""" -from __future__ import annotations - import logging from aioqsw.localapi import ConnectionOptions, QnapQswApi -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client -from .const import DOMAIN, QSW_COORD_DATA, QSW_COORD_FW -from .coordinator import QswDataCoordinator, QswFirmwareCoordinator +from .coordinator import ( + QnapQswConfigEntry, + QnapQswData, + QswDataCoordinator, + QswFirmwareCoordinator, +) _LOGGER = logging.getLogger(__name__) @@ -25,7 +26,7 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: QnapQswConfigEntry) -> bool: """Set up QNAP QSW from a config entry.""" options = ConnectionOptions( entry.data[CONF_URL], @@ -44,19 +45,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except ConfigEntryNotReady as error: _LOGGER.warning(error) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - QSW_COORD_DATA: coord_data, - QSW_COORD_FW: coord_fw, - } + entry.runtime_data = QnapQswData( + data_coordinator=coord_data, + firmware_coordinator=coord_fw, + ) 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: QnapQswConfigEntry) -> 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/qnap_qsw/binary_sensor.py b/homeassistant/components/qnap_qsw/binary_sensor.py index c1f77d068df..ed7c92ef5bb 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 @@ -20,14 +18,13 @@ 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 homeassistant.helpers.typing import UNDEFINED -from .const import ATTR_MESSAGE, DOMAIN, QSW_COORD_DATA -from .coordinator import QswDataCoordinator +from .const import ATTR_MESSAGE +from .coordinator import QnapQswConfigEntry, QswDataCoordinator from .entity import QswEntityDescription, QswEntityType, QswSensorEntity @@ -79,11 +76,11 @@ PORT_BINARY_SENSOR_TYPES: Final[tuple[QswBinarySensorEntityDescription, ...]] = async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: QnapQswConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW binary sensors from a config_entry.""" - coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA] + coordinator = entry.runtime_data.data_coordinator entities: list[QswBinarySensor] = [ QswBinarySensor(coordinator, description, entry) @@ -138,7 +135,7 @@ class QswBinarySensor(QswSensorEntity, BinarySensorEntity): self, coordinator: QswDataCoordinator, description: QswBinarySensorEntityDescription, - entry: ConfigEntry, + entry: QnapQswConfigEntry, type_id: int | None = None, ) -> None: """Initialize.""" diff --git a/homeassistant/components/qnap_qsw/button.py b/homeassistant/components/qnap_qsw/button.py index 02cf96766f2..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 @@ -13,13 +11,12 @@ 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 .const import DOMAIN, QSW_COORD_DATA, QSW_REBOOT -from .coordinator import QswDataCoordinator +from .const import QSW_REBOOT +from .coordinator import QnapQswConfigEntry, QswDataCoordinator from .entity import QswDataEntity @@ -42,11 +39,11 @@ BUTTON_TYPES: Final[tuple[QswButtonDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: QnapQswConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW buttons from a config_entry.""" - coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA] + coordinator = entry.runtime_data.data_coordinator async_add_entities( QswButton(coordinator, description, entry) for description in BUTTON_TYPES ) @@ -63,7 +60,7 @@ class QswButton(QswDataEntity, ButtonEntity): self, coordinator: QswDataCoordinator, description: QswButtonDescription, - entry: ConfigEntry, + entry: QnapQswConfigEntry, ) -> None: """Initialize.""" super().__init__(coordinator, entry) 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/const.py b/homeassistant/components/qnap_qsw/const.py index 4b5fa9a4a2c..05eeea031b5 100644 --- a/homeassistant/components/qnap_qsw/const.py +++ b/homeassistant/components/qnap_qsw/const.py @@ -10,8 +10,6 @@ MANUFACTURER: Final = "QNAP" RPM: Final = "rpm" -QSW_COORD_DATA: Final = "coordinator-data" -QSW_COORD_FW: Final = "coordinator-firmware" QSW_REBOOT = "reboot" QSW_TIMEOUT_SEC: Final = 25 QSW_UPDATE: Final = "update" diff --git a/homeassistant/components/qnap_qsw/coordinator.py b/homeassistant/components/qnap_qsw/coordinator.py index b72bed7105c..5e764e8fa27 100644 --- a/homeassistant/components/qnap_qsw/coordinator.py +++ b/homeassistant/components/qnap_qsw/coordinator.py @@ -1,8 +1,7 @@ """The QNAP QSW coordinator.""" -from __future__ import annotations - import asyncio +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any @@ -22,13 +21,24 @@ FW_SCAN_INTERVAL = timedelta(hours=12) _LOGGER = logging.getLogger(__name__) +@dataclass +class QnapQswData: + """Data for the QNAP QSW integration.""" + + data_coordinator: QswDataCoordinator + firmware_coordinator: QswFirmwareCoordinator + + +type QnapQswConfigEntry = ConfigEntry[QnapQswData] + + class QswDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching data from the QNAP QSW device.""" - config_entry: ConfigEntry + config_entry: QnapQswConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, qsw: QnapQswApi + self, hass: HomeAssistant, config_entry: QnapQswConfigEntry, qsw: QnapQswApi ) -> None: """Initialize.""" self.qsw = qsw @@ -54,10 +64,10 @@ class QswDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): class QswFirmwareCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Class to manage fetching firmware data from the QNAP QSW device.""" - config_entry: ConfigEntry + config_entry: QnapQswConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, qsw: QnapQswApi + self, hass: HomeAssistant, config_entry: QnapQswConfigEntry, qsw: QnapQswApi ) -> None: """Initialize.""" self.qsw = qsw diff --git a/homeassistant/components/qnap_qsw/diagnostics.py b/homeassistant/components/qnap_qsw/diagnostics.py index 6f42fb82cb7..b9f6580c2ca 100644 --- a/homeassistant/components/qnap_qsw/diagnostics.py +++ b/homeassistant/components/qnap_qsw/diagnostics.py @@ -1,18 +1,14 @@ """Support for the QNAP QSW diagnostics.""" -from __future__ import annotations - from typing import Any from aioqsw.const import QSD_MAC, QSD_SERIAL 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, QSW_COORD_DATA, QSW_COORD_FW -from .coordinator import QswDataCoordinator, QswFirmwareCoordinator +from .coordinator import QnapQswConfigEntry TO_REDACT_CONFIG = [ CONF_USERNAME, @@ -27,15 +23,15 @@ TO_REDACT_DATA = [ async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: QnapQswConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - coord_data: QswDataCoordinator = entry_data[QSW_COORD_DATA] - coord_fw: QswFirmwareCoordinator = entry_data[QSW_COORD_FW] - return { "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT_CONFIG), - "coord_data": async_redact_data(coord_data.data, TO_REDACT_DATA), - "coord_fw": async_redact_data(coord_fw.data, TO_REDACT_DATA), + "coord_data": async_redact_data( + config_entry.runtime_data.data_coordinator.data, TO_REDACT_DATA + ), + "coord_fw": async_redact_data( + config_entry.runtime_data.firmware_coordinator.data, TO_REDACT_DATA + ), } diff --git a/homeassistant/components/qnap_qsw/entity.py b/homeassistant/components/qnap_qsw/entity.py index a3038b1fc7b..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 @@ -16,7 +14,6 @@ from aioqsw.const import ( QSD_SYSTEM_BOARD, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo @@ -24,7 +21,7 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import MANUFACTURER -from .coordinator import QswDataCoordinator, QswFirmwareCoordinator +from .coordinator import QnapQswConfigEntry, QswDataCoordinator, QswFirmwareCoordinator class QswEntityType(StrEnum): @@ -40,7 +37,7 @@ class QswDataEntity(CoordinatorEntity[QswDataCoordinator]): def __init__( self, coordinator: QswDataCoordinator, - entry: ConfigEntry, + entry: QnapQswConfigEntry, type_id: int | None = None, ) -> None: """Initialize.""" @@ -127,7 +124,7 @@ class QswFirmwareEntity(CoordinatorEntity[QswFirmwareCoordinator]): def __init__( self, coordinator: QswFirmwareCoordinator, - entry: ConfigEntry, + entry: QnapQswConfigEntry, ) -> None: """Initialize.""" super().__init__(coordinator) diff --git a/homeassistant/components/qnap_qsw/sensor.py b/homeassistant/components/qnap_qsw/sensor.py index af02c121656..71597b34f4b 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 @@ -36,7 +34,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfDataRate, @@ -48,8 +45,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, StateType from homeassistant.util import dt as dt_util -from .const import ATTR_MAX, DOMAIN, QSW_COORD_DATA, RPM -from .coordinator import QswDataCoordinator +from .const import ATTR_MAX, RPM +from .coordinator import QnapQswConfigEntry, QswDataCoordinator from .entity import QswEntityDescription, QswEntityType, QswSensorEntity @@ -287,11 +284,11 @@ PORT_SENSOR_TYPES: Final[tuple[QswSensorEntityDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: QnapQswConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW sensors from a config_entry.""" - coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA] + coordinator = entry.runtime_data.data_coordinator entities: list[QswSensor] = [ QswSensor(coordinator, description, entry) @@ -354,7 +351,7 @@ class QswSensor(QswSensorEntity, SensorEntity): self, coordinator: QswDataCoordinator, description: QswSensorEntityDescription, - entry: ConfigEntry, + entry: QnapQswConfigEntry, type_id: int | None = None, ) -> None: """Initialize.""" diff --git a/homeassistant/components/qnap_qsw/update.py b/homeassistant/components/qnap_qsw/update.py index c5cef729849..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 ( @@ -17,13 +15,12 @@ from homeassistant.components.update import ( UpdateEntityDescription, UpdateEntityFeature, ) -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, QSW_COORD_FW, QSW_UPDATE -from .coordinator import QswFirmwareCoordinator +from .const import QSW_UPDATE +from .coordinator import QnapQswConfigEntry, QswFirmwareCoordinator from .entity import QswFirmwareEntity UPDATE_TYPES: Final[tuple[UpdateEntityDescription, ...]] = ( @@ -37,13 +34,11 @@ UPDATE_TYPES: Final[tuple[UpdateEntityDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: QnapQswConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add QNAP QSW updates from a config_entry.""" - coordinator: QswFirmwareCoordinator = hass.data[DOMAIN][entry.entry_id][ - QSW_COORD_FW - ] + coordinator = entry.runtime_data.firmware_coordinator async_add_entities( QswUpdate(coordinator, description, entry) for description in UPDATE_TYPES ) @@ -59,7 +54,7 @@ class QswUpdate(QswFirmwareEntity, UpdateEntity): self, coordinator: QswFirmwareCoordinator, description: UpdateEntityDescription, - entry: ConfigEntry, + entry: QnapQswConfigEntry, ) -> None: """Initialize.""" super().__init__(coordinator, entry) 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..901ef3384b4 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.7"] } 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..5efdd562926 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 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..2a62afba229 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 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..d17b466a6d6 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 = [ @@ -40,12 +36,11 @@ 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): @@ -61,7 +56,7 @@ class RabbitAirFanEntity(RabbitAirBaseEntity, FanEntity): def __init__( self, coordinator: RabbitAirDataUpdateCoordinator, - entry: ConfigEntry, + entry: RabbitAirConfigEntry, ) -> None: """Initialize the entity.""" super().__init__(coordinator, entry) 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..801379be5bf 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 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..b75e3a715fc 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 ( 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..dbc7f5a792c 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 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..37691cdb280 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 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..41bd5d7cf84 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 diff --git a/homeassistant/components/radio_frequency/__init__.py b/homeassistant/components/radio_frequency/__init__.py new file mode 100644 index 00000000000..4dc4cc88c84 --- /dev/null +++ b/homeassistant/components/radio_frequency/__init__.py @@ -0,0 +1,226 @@ +"""Provides functionality to interact with radio frequency devices.""" + +from abc import abstractmethod +from datetime import timedelta +import logging +from typing import final + +from rf_protocols import ModulationType, RadioFrequencyCommand + +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.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 + +__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) + + +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/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/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..70797a9cb87 --- /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==2.2.0"] +} 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 80dbcf44bc9..dfba8330082 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -1,6 +1,4 @@ -"""The radiotherm component.""" - -from __future__ import annotations +"""The Radio Thermostat integration.""" from collections.abc import Coroutine from typing import Any @@ -8,13 +6,11 @@ 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 4803cacd84b..aa56edf41ae 100644 --- a/homeassistant/components/radiotherm/data.py +++ b/homeassistant/components/radiotherm/data.py @@ -1,6 +1,4 @@ -"""The radiotherm component data.""" - -from __future__ import annotations +"""The Radio Thermostat integration data.""" from dataclasses import dataclass from typing import Any @@ -16,7 +14,7 @@ from .const import TIMEOUT @dataclass class RadioThermUpdate: - """An update from a radiotherm device.""" + """An update from a Radio Thermostat device.""" tstat: dict[str, Any] humidity: int | None diff --git a/homeassistant/components/radiotherm/entity.py b/homeassistant/components/radiotherm/entity.py index 384c97cac2c..77353366248 100644 --- a/homeassistant/components/radiotherm/entity.py +++ b/homeassistant/components/radiotherm/entity.py @@ -1,4 +1,4 @@ -"""The radiotherm integration base entity.""" +"""The Radio Thermostat integration base entity.""" from abc import abstractmethod @@ -12,7 +12,7 @@ from .data import RadioThermUpdate class RadioThermostatEntity(CoordinatorEntity[RadioThermUpdateCoordinator]): - """Base class for radiotherm entities.""" + """Base class for Radio Thermostat entities.""" _attr_has_entity_name = True 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..ba692e7c40f 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 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..55bc0fdf152 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 diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 9563d9b7268..b8a77b87faf 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyrainbird"], - "requirements": ["pyrainbird==6.1.1"] + "requirements": ["pyrainbird==6.3.0"] } 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/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 867bc5886db..8ea72d93ba2 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 @@ -10,7 +8,14 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_TYPE -from .const import CONF_CLOUD_ID, CONF_HARDWARE_ADDRESS, CONF_INSTALL_CODE, DOMAIN +from .const import ( + CONF_CLOUD_ID, + CONF_HARDWARE_ADDRESS, + CONF_INSTALL_CODE, + DOMAIN, + TYPE_EAGLE_100, + TYPE_EAGLE_200, +) from .data import CannotConnect, InvalidAuth, async_get_type _LOGGER = logging.getLogger(__name__) @@ -63,11 +68,32 @@ class RainforestEagleConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - user_input[CONF_TYPE] = eagle_type - user_input[CONF_HARDWARE_ADDRESS] = hardware_address - return self.async_create_entry( - title=user_input[CONF_CLOUD_ID], data=user_input - ) + # Verify it is a known device, first + if not eagle_type: + errors["base"] = "unknown_device_type" + 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 + 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 + if not hardware_address: + # 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 + errors["base"] = "unsupported_device_type" + + # 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 + ) return self.async_show_form( step_id="user", data_schema=create_schema(user_input), errors=errors 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 01f373f3178..5edfcb73551 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 @@ -34,7 +32,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 meter too. + # For EAGLE-200, fetch the hardware address of the first connected meter, too. hub = aioeagle.EagleHub( aiohttp_client.async_get_clientsession(hass), cloud_id, install_code, host=host ) @@ -50,8 +48,17 @@ async def async_get_type(hass, cloud_id, install_code, host): if meters is not None: if meters: - hardware_address = meters[0].hardware_address + # If there is at least one meter, use the first one with a connection status of "Connected" + hardware_address = next( + ( + m.hardware_address + for m in meters + if getattr(m, "connection_status", None) == "Connected" + ), + None, + ) else: + # 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 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..47bb4716ecd 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"): diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json index a874770baa9..b3eed05110c 100644 --- a/homeassistant/components/rainforest_eagle/strings.json +++ b/homeassistant/components/rainforest_eagle/strings.json @@ -6,7 +6,10 @@ "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%]" + "no_meters_connected": "No meters are currently connected. Ensure your meter is connected and try again.", + "unknown": "[%key:common::config_flow::error::unknown%]", + "unknown_device_type": "Unable to determine the type of Rainforest Eagle device. Please ensure your device is supported.", + "unsupported_device_type": "This type of Rainforest Eagle device is not supported." }, "step": { "user": { 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..5338e483e2e 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}" ) @@ -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..71b876c72af 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 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..deb3acd1875 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=hass-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/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..326be35b9b5 100644 --- a/homeassistant/components/rapt_ble/config_flow.py +++ b/homeassistant/components/rapt_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for rapt_ble.""" -from __future__ import annotations - from typing import Any from rapt_ble import RAPTPillBluetoothDeviceData as DeviceData diff --git a/homeassistant/components/rapt_ble/sensor.py b/homeassistant/components/rapt_ble/sensor.py index 01aeedbd344..b66283f452d 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,13 +94,11 @@ 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( diff --git a/homeassistant/components/raspberry_pi/__init__.py b/homeassistant/components/raspberry_pi/__init__.py index 8095eb9dfe0..9384f83b720 100644 --- a/homeassistant/components/raspberry_pi/__init__.py +++ b/homeassistant/components/raspberry_pi/__init__.py @@ -1,7 +1,5 @@ """The Raspberry Pi integration.""" -from __future__ import annotations - from homeassistant.components.hassio import get_os_info from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant 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..b3964e29641 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 diff --git a/homeassistant/components/raspyrfm/switch.py b/homeassistant/components/raspyrfm/switch.py index 19a1b724c48..5c23790ef25 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 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..1702ef3f22d 100644 --- a/homeassistant/components/recollect_waste/calendar.py +++ b/homeassistant/components/recollect_waste/calendar.py @@ -1,25 +1,21 @@ """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 .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 +32,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 +48,7 @@ class ReCollectWasteCalendar(ReCollectWasteEntity, CalendarEntity): def __init__( self, coordinator: ReCollectWasteDataUpdateCoordinator, - entry: ConfigEntry, + entry: RecollectWasteConfigEntry, ) -> None: """Initialize the ReCollect Waste entity.""" super().__init__(coordinator, entry) 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..d90390773bb 100644 --- a/homeassistant/components/recollect_waste/coordinator.py +++ b/homeassistant/components/recollect_waste/coordinator.py @@ -1,7 +1,5 @@ """Data update coordinator for ReCollect Waste.""" -from __future__ import annotations - from datetime import date, timedelta from aiorecollect.client import Client, PickupEvent @@ -14,15 +12,19 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda 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, 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..7fb48bc5876 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -1,7 +1,5 @@ """Support for ReCollect Waste sensors.""" -from __future__ import annotations - from datetime import date from homeassistant.components.sensor import ( @@ -9,12 +7,11 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -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 ReCollectWasteDataUpdateCoordinator +from .const import LOGGER +from .coordinator import RecollectWasteConfigEntry, ReCollectWasteDataUpdateCoordinator from .entity import ReCollectWasteEntity from .util import async_get_pickup_type_names @@ -38,14 +35,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 +58,7 @@ class ReCollectWasteSensor(ReCollectWasteEntity, SensorEntity): def __init__( self, coordinator: ReCollectWasteDataUpdateCoordinator, - entry: ConfigEntry, + entry: RecollectWasteConfigEntry, description: SensorEntityDescription, ) -> None: """Initialize.""" 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..fb668d50aa3 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 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..4a1c839067a 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 diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 65de7e853a3..89a91b48008 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 @@ -192,7 +190,7 @@ 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 +204,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..2a9f8ee30da 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 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..b71725e0786 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.49", + "fnv-hash-fast==2.0.2", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 9430ba52e33..07d578867ac 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 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..e9d3a4c4003 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 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..23c5b0a4b40 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, diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json index 35286836318..d0afa2d3ddf 100644 --- a/homeassistant/components/recorder/strings.json +++ b/homeassistant/components/recorder/strings.json @@ -12,11 +12,11 @@ "services": { "disable": { "description": "Stops the recording of events and state changes.", - "name": "[%key:common::action::disable%]" + "name": "Disable Recorder" }, "enable": { "description": "Starts the recording of events and state changes.", - "name": "[%key:common::action::enable%]" + "name": "Enable Recorder" }, "get_statistics": { "description": "Retrieves statistics data for entities within a specific time period.", @@ -46,7 +46,7 @@ "name": "Units" } }, - "name": "Get statistics" + "name": "Get Recorder statistics" }, "purge": { "description": "Starts purge task - to clean up old data from your database.", @@ -64,7 +64,7 @@ "name": "Repack" } }, - "name": "Purge" + "name": "Purge Recorder database" }, "purge_entities": { "description": "Starts a purge task to remove the data related to specific entities from your database.", @@ -86,7 +86,7 @@ "name": "[%key:component::recorder::services::purge::fields::keep_days::name%]" } }, - "name": "Purge entities" + "name": "Purge Recorder entities" } }, "system_health": { 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..419017f9405 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 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..2f6f2d6afb6 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 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..12a48bcc2b6 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 diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py index ce660bccc01..75220bb0e9b 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 diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py index 9ce021c59a5..afa6aca3ad4 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 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..9730bc67998 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 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/rehlko/strings.json b/homeassistant/components/rehlko/strings.json index e802d234c93..3950a2eb7d9 100644 --- a/homeassistant/components/rehlko/strings.json +++ b/homeassistant/components/rehlko/strings.json @@ -122,7 +122,7 @@ }, "exceptions": { "cannot_connect": { - "message": "Can not connect to Rehlko servers." + "message": "Cannot connect to Rehlko servers." }, "invalid_auth": { "message": "Authentication failed for email {email}." 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 e2f6af02673..3603c54df1b 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -1,7 +1,35 @@ { "common": { - "trigger_behavior_description": "The behavior of the targeted remotes to trigger on.", - "trigger_behavior_name": "Behavior" + "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": { @@ -31,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.", @@ -53,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.", @@ -79,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.", @@ -105,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.", @@ -123,7 +142,7 @@ "name": "Activity" } }, - "name": "[%key:common::action::turn_on%]" + "name": "Turn on via remote" } }, "title": "Remote", @@ -132,8 +151,10 @@ "description": "Triggers when one or more remotes turn off.", "fields": { "behavior": { - "description": "[%key:component::remote::common::trigger_behavior_description%]", "name": "[%key:component::remote::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::remote::common::trigger_for_name%]" } }, "name": "Remote turned off" @@ -142,8 +163,10 @@ "description": "Triggers when one or more remotes turn on.", "fields": { "behavior": { - "description": "[%key:component::remote::common::trigger_behavior_description%]", "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..559577735cf 100644 --- a/homeassistant/components/remote/triggers.yaml +++ b/homeassistant/components/remote/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any 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_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/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 5e4f08e9d5c..4a1338d5dd6 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -1,12 +1,15 @@ """Support for Renault binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from renault_api.kamereon.enums import ChargeState, PlugState -from renault_api.kamereon.models import KamereonVehicleBatteryStatusData +from renault_api.kamereon.models import ( + KamereonVehicleBatteryStatusData, + KamereonVehicleDataAttributes, + KamereonVehicleHvacStatusData, + KamereonVehicleLockStatusData, +) from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -15,7 +18,6 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.typing import StateType from . import RenaultConfigEntry from .entity import RenaultDataEntity, RenaultDataEntityDescription @@ -35,15 +37,13 @@ _PLUG_FROM_CHARGE_STATUS: set[ChargeState] = { @dataclass(frozen=True, kw_only=True) -class RenaultBinarySensorEntityDescription( +class RenaultBinarySensorEntityDescription[T: KamereonVehicleDataAttributes]( BinarySensorEntityDescription, RenaultDataEntityDescription, ): """Class describing Renault binary sensor entities.""" - on_key: str | None = None - on_value: StateType | None = None - value_lambda: Callable[[RenaultBinarySensor], bool | None] | None = None + value_lambda: Callable[[RenaultBinarySensor[T]], bool | None] async def async_setup_entry( @@ -61,93 +61,130 @@ async def async_setup_entry( async_add_entities(entities) -class RenaultBinarySensor( - RenaultDataEntity[KamereonVehicleBatteryStatusData], BinarySensorEntity +class RenaultBinarySensor[T: KamereonVehicleDataAttributes]( + RenaultDataEntity[T], BinarySensorEntity ): """Mixin for binary sensor specific attributes.""" - entity_description: RenaultBinarySensorEntityDescription + entity_description: RenaultBinarySensorEntityDescription[T] @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - - if self.entity_description.value_lambda is not None: - return self.entity_description.value_lambda(self) - if self.entity_description.on_key is None: - raise NotImplementedError("Either value_lambda or on_key must be set") - if (data := self._get_data_attr(self.entity_description.on_key)) is None: - return None - - return data == self.entity_description.on_value + return self.entity_description.value_lambda(self) -def _plugged_in_value_lambda(self: RenaultBinarySensor) -> bool | None: +def _plugged_in_value_lambda( + self: RenaultBinarySensor[KamereonVehicleBatteryStatusData], +) -> bool | None: """Return true if the vehicle is plugged in.""" - - data = self.coordinator.data - plug_status = data.get_plug_status() if data else None - - if plug_status is not None: + if (plug_status := self.coordinator.data.get_plug_status()) is not None: return plug_status == PlugState.PLUGGED - charging_status = data.get_charging_status() if data else None - if charging_status is not None and charging_status in _PLUG_FROM_CHARGE_STATUS: + if ( + charging_status := self.coordinator.data.get_charging_status() + ) is not None and charging_status in _PLUG_FROM_CHARGE_STATUS: return True return None -BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = tuple( - [ - RenaultBinarySensorEntityDescription( - key="plugged_in", - coordinator="battery", - device_class=BinarySensorDeviceClass.PLUG, - value_lambda=_plugged_in_value_lambda, +BINARY_SENSOR_TYPES: tuple[RenaultBinarySensorEntityDescription, ...] = ( + RenaultBinarySensorEntityDescription[KamereonVehicleBatteryStatusData]( + key="plugged_in", + coordinator="battery", + device_class=BinarySensorDeviceClass.PLUG, + value_lambda=_plugged_in_value_lambda, + ), + RenaultBinarySensorEntityDescription[KamereonVehicleBatteryStatusData]( + key="charging", + coordinator="battery", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + value_lambda=lambda e: ( + e.coordinator.data.chargingStatus == ChargeState.CHARGE_IN_PROGRESS.value + if e.coordinator.data.chargingStatus is not None + else None ), - RenaultBinarySensorEntityDescription( - key="charging", - coordinator="battery", - device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - on_key="chargingStatus", - on_value=ChargeState.CHARGE_IN_PROGRESS.value, + ), + RenaultBinarySensorEntityDescription[KamereonVehicleHvacStatusData]( + key="hvac_status", + coordinator="hvac_status", + translation_key="hvac_status", + value_lambda=lambda e: ( + e.coordinator.data.hvacStatus == "on" + if e.coordinator.data.hvacStatus is not None + else None ), - RenaultBinarySensorEntityDescription( - key="hvac_status", - coordinator="hvac_status", - on_key="hvacStatus", - on_value="on", - translation_key="hvac_status", + ), + RenaultBinarySensorEntityDescription[KamereonVehicleLockStatusData]( + key="lock_status", + coordinator="lock_status", + # lock: on means open (unlocked), off means closed (locked) + device_class=BinarySensorDeviceClass.LOCK, + value_lambda=lambda e: ( + e.coordinator.data.lockStatus == "unlocked" + if e.coordinator.data.lockStatus is not None + else None ), - RenaultBinarySensorEntityDescription( - key="lock_status", - coordinator="lock_status", - # lock: on means open (unlocked), off means closed (locked) - device_class=BinarySensorDeviceClass.LOCK, - on_key="lockStatus", - on_value="unlocked", + ), + RenaultBinarySensorEntityDescription[KamereonVehicleLockStatusData]( + key="hatch_status", + coordinator="lock_status", + # On means open, Off means closed + device_class=BinarySensorDeviceClass.DOOR, + translation_key="hatch_status", + value_lambda=lambda e: ( + e.coordinator.data.hatchStatus == "open" + if e.coordinator.data.hatchStatus is not None + else None ), - RenaultBinarySensorEntityDescription( - key="hatch_status", - coordinator="lock_status", - # On means open, Off means closed - device_class=BinarySensorDeviceClass.DOOR, - on_key="hatchStatus", - on_value="open", - translation_key="hatch_status", + ), + RenaultBinarySensorEntityDescription[KamereonVehicleLockStatusData]( + key="rear_left_door_status", + coordinator="lock_status", + # On means open, Off means closed + device_class=BinarySensorDeviceClass.DOOR, + translation_key="rear_left_door_status", + value_lambda=lambda e: ( + e.coordinator.data.doorStatusRearLeft == "open" + if e.coordinator.data.doorStatusRearLeft is not None + else None ), - ] - + [ - RenaultBinarySensorEntityDescription( - key=f"{door.replace(' ', '_').lower()}_door_status", - coordinator="lock_status", - # On means open, Off means closed - device_class=BinarySensorDeviceClass.DOOR, - on_key=f"doorStatus{door.replace(' ', '')}", - on_value="open", - translation_key=f"{door.lower().replace(' ', '_')}_door_status", - ) - for door in ("Rear Left", "Rear Right", "Driver", "Passenger") - ], + ), + RenaultBinarySensorEntityDescription[KamereonVehicleLockStatusData]( + key="rear_right_door_status", + coordinator="lock_status", + # On means open, Off means closed + device_class=BinarySensorDeviceClass.DOOR, + translation_key="rear_right_door_status", + value_lambda=lambda e: ( + e.coordinator.data.doorStatusRearRight == "open" + if e.coordinator.data.doorStatusRearRight is not None + else None + ), + ), + RenaultBinarySensorEntityDescription[KamereonVehicleLockStatusData]( + key="driver_door_status", + coordinator="lock_status", + # On means open, Off means closed + device_class=BinarySensorDeviceClass.DOOR, + translation_key="driver_door_status", + value_lambda=lambda e: ( + e.coordinator.data.doorStatusDriver == "open" + if e.coordinator.data.doorStatusDriver is not None + else None + ), + ), + RenaultBinarySensorEntityDescription[KamereonVehicleLockStatusData]( + key="passenger_door_status", + coordinator="lock_status", + # On means open, Off means closed + device_class=BinarySensorDeviceClass.DOOR, + translation_key="passenger_door_status", + value_lambda=lambda e: ( + e.coordinator.data.doorStatusPassenger == "open" + if e.coordinator.data.doorStatusPassenger is not None + else None + ), + ), ) 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..f8b34813766 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -1,7 +1,5 @@ """Config flow to configure Renault component.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py index c768c436133..b2e3ba9bb0e 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -1,12 +1,10 @@ """Proxy to handle account communication with Renault servers.""" -from __future__ import annotations - import asyncio from collections.abc import Awaitable, Callable from datetime import timedelta import logging -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING from renault_api.kamereon.exceptions import ( AccessDeniedException, @@ -23,13 +21,13 @@ if TYPE_CHECKING: from . import RenaultConfigEntry from .renault_hub import RenaultHub -T = TypeVar("T", bound=KamereonVehicleDataAttributes) - # We have potentially 7 coordinators per vehicle _PARALLEL_SEMAPHORE = asyncio.Semaphore(1) -class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]): +class RenaultDataUpdateCoordinator[T: KamereonVehicleDataAttributes]( + DataUpdateCoordinator[T] +): """Handle vehicle communication with Renault servers.""" config_entry: RenaultConfigEntry diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index c55ddeb2190..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 @@ -52,12 +50,12 @@ class RenaultDeviceTracker( @property def latitude(self) -> float | None: """Return latitude value of the device.""" - return self.coordinator.data.gpsLatitude if self.coordinator.data else None + return self.coordinator.data.gpsLatitude @property def longitude(self) -> float | None: """Return longitude value of the device.""" - return self.coordinator.data.gpsLongitude if self.coordinator.data else None + return self.coordinator.data.gpsLongitude DEVICE_TRACKER_TYPES: tuple[RenaultTrackerEntityDescription, ...] = ( diff --git a/homeassistant/components/renault/diagnostics.py b/homeassistant/components/renault/diagnostics.py index 5d1849f4b20..fdaac55511d 100644 --- a/homeassistant/components/renault/diagnostics.py +++ b/homeassistant/components/renault/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for Renault.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -56,8 +54,12 @@ def _get_vehicle_diagnostics(vehicle: RenaultVehicleProxy) -> dict[str, Any]: return { "details": async_redact_data(vehicle.details.raw_data, TO_REDACT), "data": { - key: async_redact_data( - coordinator.data.raw_data if coordinator.data else None, TO_REDACT + key: ( + async_redact_data(coordinator.data.raw_data, TO_REDACT) + # Renault coordinators override async_config_entry_first_refresh + # to not raise ConfigEntryNotReady, so coordinator data can be None + if coordinator.data + else None ) for key, coordinator in vehicle.coordinators.items() }, diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py index 81d81a18b7f..21e4988d8fc 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -1,30 +1,23 @@ """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, T +from .coordinator import RenaultDataUpdateCoordinator from .renault_vehicle import RenaultVehicleProxy -@dataclass(frozen=True) -class RenaultDataRequiredKeysMixin: - """Mixin for required keys.""" +@dataclass(frozen=True, kw_only=True) +class RenaultDataEntityDescription(EntityDescription): + """Class describing Renault data entities.""" coordinator: str -@dataclass(frozen=True) -class RenaultDataEntityDescription(EntityDescription, RenaultDataRequiredKeysMixin): - """Class describing Renault data entities.""" - - class RenaultEntity(Entity): """Implementation of a Renault entity with a data coordinator.""" @@ -43,7 +36,7 @@ class RenaultEntity(Entity): self._attr_unique_id = f"{self.vehicle.details.vin}_{description.key}".lower() -class RenaultDataEntity( +class RenaultDataEntity[T: KamereonVehicleDataAttributes]( CoordinatorEntity[RenaultDataUpdateCoordinator[T]], RenaultEntity ): """Implementation of a Renault entity with a data coordinator.""" @@ -57,10 +50,6 @@ class RenaultDataEntity( 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/number.py b/homeassistant/components/renault/number.py index 555bb9b9e72..b4cbc922af0 100644 --- a/homeassistant/components/renault/number.py +++ b/homeassistant/components/renault/number.py @@ -1,12 +1,13 @@ """Support for Renault number entities.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any, cast +from typing import Any -from renault_api.kamereon.models import KamereonVehicleBatterySocData +from renault_api.kamereon.models import ( + KamereonVehicleBatterySocData, + KamereonVehicleDataAttributes, +) from homeassistant.components.number import ( NumberDeviceClass, @@ -29,23 +30,23 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class RenaultNumberEntityDescription( +class RenaultNumberEntityDescription[T: KamereonVehicleDataAttributes]( NumberEntityDescription, RenaultDataEntityDescription ): """Class describing Renault number entities.""" - data_key: str - update_fn: Callable[[RenaultNumberEntity, float], Coroutine[Any, Any, None]] + value_fn: Callable[[RenaultNumberEntity[T]], float | None] + update_fn: Callable[[RenaultNumberEntity[T], float], Coroutine[Any, Any, None]] -async def _set_charge_limit_min(entity: RenaultNumberEntity, value: float) -> None: +async def _set_charge_limit_min( + entity: RenaultNumberEntity[KamereonVehicleBatterySocData], value: float +) -> None: """Set the minimum SOC. The target SOC is required to set the minimum SOC, so we need to fetch it first. """ - if (data := entity.coordinator.data) is None or ( - target_soc := data.socTarget - ) is None: + if (target_soc := entity.coordinator.data.socTarget) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="battery_soc_unavailable", @@ -53,12 +54,14 @@ async def _set_charge_limit_min(entity: RenaultNumberEntity, value: float) -> No await _set_charge_limits(entity, min_soc=round(value), target_soc=target_soc) -async def _set_charge_limit_target(entity: RenaultNumberEntity, value: float) -> None: +async def _set_charge_limit_target( + entity: RenaultNumberEntity[KamereonVehicleBatterySocData], value: float +) -> None: """Set the target SOC. The minimum SOC is required to set the target SOC, so we need to fetch it first. """ - if (data := entity.coordinator.data) is None or (min_soc := data.socMin) is None: + if (min_soc := entity.coordinator.data.socMin) is None: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="battery_soc_unavailable", @@ -67,7 +70,9 @@ async def _set_charge_limit_target(entity: RenaultNumberEntity, value: float) -> async def _set_charge_limits( - entity: RenaultNumberEntity, min_soc: int, target_soc: int + entity: RenaultNumberEntity[KamereonVehicleBatterySocData], + min_soc: int, + target_soc: int, ) -> None: """Set the minimum and target SOC. @@ -79,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) @@ -97,17 +103,17 @@ async def async_setup_entry( async_add_entities(entities) -class RenaultNumberEntity( - RenaultDataEntity[KamereonVehicleBatterySocData], NumberEntity +class RenaultNumberEntity[T: KamereonVehicleDataAttributes]( + RenaultDataEntity[T], NumberEntity ): """Mixin for number specific attributes.""" - entity_description: RenaultNumberEntityDescription + entity_description: RenaultNumberEntityDescription[T] @property def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" - return cast(float | None, self._get_data_attr(self.entity_description.data_key)) + return self.entity_description.value_fn(self) async def async_set_native_value(self, value: float) -> None: """Update the current value.""" @@ -115,10 +121,9 @@ class RenaultNumberEntity( NUMBER_TYPES: tuple[RenaultNumberEntityDescription, ...] = ( - RenaultNumberEntityDescription( + RenaultNumberEntityDescription[KamereonVehicleBatterySocData]( key="charge_limit_min", coordinator="battery_soc", - data_key="socMin", update_fn=_set_charge_limit_min, device_class=NumberDeviceClass.BATTERY, native_min_value=15, @@ -127,11 +132,11 @@ NUMBER_TYPES: tuple[RenaultNumberEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, mode=NumberMode.SLIDER, translation_key="charge_limit_min", + value_fn=lambda entity: entity.coordinator.data.socMin, ), - RenaultNumberEntityDescription( + RenaultNumberEntityDescription[KamereonVehicleBatterySocData]( key="charge_limit_target", coordinator="battery_soc", - data_key="socTarget", update_fn=_set_charge_limit_target, device_class=NumberDeviceClass.BATTERY, native_min_value=55, @@ -140,5 +145,6 @@ NUMBER_TYPES: tuple[RenaultNumberEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, mode=NumberMode.SLIDER, translation_key="charge_limit_target", + value_fn=lambda entity: entity.coordinator.data.socTarget, ), ) diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py index cd6b43f3662..e048976ecd4 100644 --- a/homeassistant/components/renault/renault_hub.py +++ b/homeassistant/components/renault/renault_hub.py @@ -1,7 +1,5 @@ """Proxy to handle account communication with Renault servers.""" -from __future__ import annotations - import asyncio from datetime import timedelta import logging 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 cddf83bb860..c19d58f7ae9 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -1,16 +1,17 @@ """Support for Renault sensors.""" -from __future__ import annotations - +from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import cast +from typing import Any -from renault_api.kamereon.models import KamereonVehicleBatteryStatusData +from renault_api.kamereon.models import ( + KamereonVehicleChargeModeData, + KamereonVehicleDataAttributes, +) from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.typing import StateType from . import RenaultConfigEntry from .entity import RenaultDataEntity, RenaultDataEntityDescription @@ -21,12 +22,13 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) -class RenaultSelectEntityDescription( +class RenaultSelectEntityDescription[T: KamereonVehicleDataAttributes]( SelectEntityDescription, RenaultDataEntityDescription ): """Class describing Renault select entities.""" - data_key: str + value_fn: Callable[[RenaultSelectEntity[T]], str | None] + update_fn: Callable[[RenaultSelectEntity[T], str], Coroutine[Any, Any, Any]] async def async_setup_entry( @@ -44,34 +46,30 @@ async def async_setup_entry( async_add_entities(entities) -class RenaultSelectEntity( - RenaultDataEntity[KamereonVehicleBatteryStatusData], SelectEntity +class RenaultSelectEntity[T: KamereonVehicleDataAttributes]( + RenaultDataEntity[T], SelectEntity ): """Mixin for sensor specific attributes.""" - entity_description: RenaultSelectEntityDescription + entity_description: RenaultSelectEntityDescription[T] @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - return cast(str, self.data) - - @property - def data(self) -> StateType: - """Return the state of this entity.""" - return self._get_data_attr(self.entity_description.data_key) + return self.entity_description.value_fn(self) async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await self.vehicle.set_charge_mode(option) + await self.entity_description.update_fn(self, option) SENSOR_TYPES: tuple[RenaultSelectEntityDescription, ...] = ( - RenaultSelectEntityDescription( + RenaultSelectEntityDescription[KamereonVehicleChargeModeData]( key="charge_mode", coordinator="charge_mode", - data_key="chargeMode", translation_key="charge_mode", options=["always", "always_charging", "schedule_mode", "scheduled"], + update_fn=lambda e, option: e.vehicle.set_charge_mode(option), + value_fn=lambda e: e.coordinator.data.chargeMode, ), ) diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 66e1a4be93b..3faa217eb34 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -1,16 +1,15 @@ """Support for Renault sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import TYPE_CHECKING, Any, Generic, cast +from typing import Any from renault_api.kamereon.models import ( KamereonVehicleBatteryStatusData, KamereonVehicleChargingSettingsData, KamereonVehicleCockpitData, + KamereonVehicleDataAttributes, KamereonVehicleHvacStatusData, KamereonVehicleLocationData, KamereonVehicleResStateData, @@ -39,7 +38,6 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import as_utc, parse_datetime from . import RenaultConfigEntry -from .coordinator import T from .entity import RenaultDataEntity, RenaultDataEntityDescription from .renault_vehicle import RenaultVehicleProxy @@ -48,16 +46,14 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) -class RenaultSensorEntityDescription( - SensorEntityDescription, RenaultDataEntityDescription, Generic[T] +class RenaultSensorEntityDescription[T: KamereonVehicleDataAttributes]( + SensorEntityDescription, RenaultDataEntityDescription ): """Class describing Renault sensor entities.""" - data_key: str - entity_class: type[RenaultSensor[T]] condition_lambda: Callable[[RenaultVehicleProxy], bool] | None = None requires_fuel: bool = False - value_lambda: Callable[[RenaultSensor[T]], StateType | datetime] | None = None + value_lambda: Callable[[RenaultSensor[T]], StateType | datetime] async def async_setup_entry( @@ -67,7 +63,7 @@ async def async_setup_entry( ) -> None: """Set up the Renault entities from config entry.""" entities: list[RenaultSensor[Any]] = [ - description.entity_class(vehicle, description) + RenaultSensor(vehicle, description) for vehicle in config_entry.runtime_data.vehicles.values() for description in SENSOR_TYPES if description.coordinator in vehicle.coordinators @@ -77,82 +73,71 @@ async def async_setup_entry( async_add_entities(entities) -class RenaultSensor(RenaultDataEntity[T], SensorEntity): +class RenaultSensor[T: KamereonVehicleDataAttributes]( + RenaultDataEntity[T], SensorEntity +): """Mixin for sensor specific attributes.""" entity_description: RenaultSensorEntityDescription[T] - @property - def data(self) -> StateType: - """Return the state of this entity.""" - return self._get_data_attr(self.entity_description.data_key) - @property def native_value(self) -> StateType | datetime: """Return the state of this entity.""" - if self.data is None: - return None - if self.entity_description.value_lambda is None: - return self.data return self.entity_description.value_lambda(self) -def _get_charging_power(entity: RenaultSensor[T]) -> StateType: - """Return the charging_power of this entity.""" - return cast(float, entity.data) / 1000 - - -def _get_charge_state_formatted(entity: RenaultSensor[T]) -> str | None: +def _get_charge_state_formatted( + entity: RenaultSensor[KamereonVehicleBatteryStatusData], +) -> str | None: """Return the charging_status of this entity.""" - data = cast(KamereonVehicleBatteryStatusData, entity.coordinator.data) - charging_status = data.get_charging_status() if data else None + charging_status = entity.coordinator.data.get_charging_status() return charging_status.name.lower() if charging_status else None -def _get_plug_state_formatted(entity: RenaultSensor[T]) -> str | None: +def _get_plug_state_formatted( + entity: RenaultSensor[KamereonVehicleBatteryStatusData], +) -> str | None: """Return the plug_status of this entity.""" - data = cast(KamereonVehicleBatteryStatusData, entity.coordinator.data) - plug_status = data.get_plug_status() if data else None + plug_status = entity.coordinator.data.get_plug_status() return plug_status.name.lower() if plug_status else None -def _get_rounded_value(entity: RenaultSensor[T]) -> float: +def _get_rounded_value(value: float | None) -> int | None: """Return the rounded value of this entity.""" - return round(cast(float, entity.data)) + if value is None: + return None + return round(value) -def _get_utc_value(entity: RenaultSensor[T]) -> datetime: +def _get_utc_value(value: str | None) -> datetime | None: """Return the UTC value of this entity.""" - original_dt = parse_datetime(cast(str, entity.data)) - if TYPE_CHECKING: - assert original_dt is not None + if (value is None) or (original_dt := parse_datetime(value)) is None: + return None return as_utc(original_dt) -def _get_charging_settings_mode_formatted(entity: RenaultSensor[T]) -> str | None: +def _get_charging_settings_mode_formatted( + entity: RenaultSensor[KamereonVehicleChargingSettingsData], +) -> str | None: """Return the charging_settings mode of this entity.""" - data = cast(KamereonVehicleChargingSettingsData, entity.coordinator.data) - charging_mode = data.mode if data else None + charging_mode = entity.coordinator.data.mode return charging_mode.lower() if charging_mode else None SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_level", coordinator="battery", - data_key="batteryLevel", device_class=SensorDeviceClass.BATTERY, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + value_lambda=lambda e: e.coordinator.data.batteryLevel, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="charge_state", coordinator="battery", - data_key="chargingStatus", translation_key="charge_state", device_class=SensorDeviceClass.ENUM, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], options=[ "not_in_charge", "waiting_for_a_planned_charge", @@ -165,51 +150,46 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( ], value_lambda=_get_charge_state_formatted, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="charging_remaining_time", coordinator="battery", - data_key="chargingRemainingTime", device_class=SensorDeviceClass.DURATION, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, translation_key="charging_remaining_time", + value_lambda=lambda e: e.coordinator.data.chargingRemainingTime, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( # For vehicles that DO NOT report charging power in watts, this seems to # correspond to the maximum power that would be admissible by the car based # on the battery state, regardless of the type of charger. key="charging_power", condition_lambda=lambda a: not a.details.reports_charging_power_in_watts(), coordinator="battery", - data_key="chargingInstantaneousPower", device_class=SensorDeviceClass.POWER, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], native_unit_of_measurement=UnitOfPower.KILO_WATT, state_class=SensorStateClass.MEASUREMENT, translation_key="admissible_charging_power", + value_lambda=lambda e: e.coordinator.data.chargingInstantaneousPower, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( # For vehicles that DO report charging power in watts, this is the power # effectively being transferred to the car. key="charging_power", condition_lambda=lambda a: a.details.reports_charging_power_in_watts(), coordinator="battery", - data_key="chargingInstantaneousPower", device_class=SensorDeviceClass.POWER, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], - 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( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="plug_state", coordinator="battery", - data_key="plugStatus", translation_key="plug_state", device_class=SensorDeviceClass.ENUM, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], options=[ "unplugged", "plugged", @@ -219,140 +199,119 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( ], value_lambda=_get_plug_state_formatted, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_autonomy", coordinator="battery", - data_key="batteryAutonomy", device_class=SensorDeviceClass.DISTANCE, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, translation_key="battery_autonomy", + value_lambda=lambda e: e.coordinator.data.batteryAutonomy, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_available_energy", coordinator="battery", - data_key="batteryAvailableEnergy", - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, translation_key="battery_available_energy", + value_lambda=lambda e: e.coordinator.data.batteryAvailableEnergy, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_temperature", coordinator="battery", - data_key="batteryTemperature", device_class=SensorDeviceClass.TEMPERATURE, - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, translation_key="battery_temperature", + value_lambda=lambda e: e.coordinator.data.batteryTemperature, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleBatteryStatusData]( key="battery_last_activity", coordinator="battery", device_class=SensorDeviceClass.TIMESTAMP, - data_key="timestamp", - entity_class=RenaultSensor[KamereonVehicleBatteryStatusData], entity_registry_enabled_default=False, - value_lambda=_get_utc_value, + value_lambda=lambda e: _get_utc_value(e.coordinator.data.timestamp), translation_key="battery_last_activity", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleCockpitData]( key="mileage", coordinator="cockpit", - data_key="totalMileage", device_class=SensorDeviceClass.DISTANCE, - entity_class=RenaultSensor[KamereonVehicleCockpitData], native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.TOTAL_INCREASING, - value_lambda=_get_rounded_value, + value_lambda=lambda e: _get_rounded_value(e.coordinator.data.totalMileage), translation_key="mileage", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleCockpitData]( key="fuel_autonomy", coordinator="cockpit", - data_key="fuelAutonomy", device_class=SensorDeviceClass.DISTANCE, - entity_class=RenaultSensor[KamereonVehicleCockpitData], native_unit_of_measurement=UnitOfLength.KILOMETERS, state_class=SensorStateClass.MEASUREMENT, requires_fuel=True, - value_lambda=_get_rounded_value, + value_lambda=lambda e: _get_rounded_value(e.coordinator.data.fuelAutonomy), translation_key="fuel_autonomy", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleCockpitData]( key="fuel_quantity", coordinator="cockpit", - data_key="fuelQuantity", device_class=SensorDeviceClass.VOLUME, - entity_class=RenaultSensor[KamereonVehicleCockpitData], native_unit_of_measurement=UnitOfVolume.LITERS, state_class=SensorStateClass.TOTAL, requires_fuel=True, - value_lambda=_get_rounded_value, + value_lambda=lambda e: _get_rounded_value(e.coordinator.data.fuelQuantity), translation_key="fuel_quantity", ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleHvacStatusData]( key="outside_temperature", coordinator="hvac_status", device_class=SensorDeviceClass.TEMPERATURE, - data_key="externalTemperature", - entity_class=RenaultSensor[KamereonVehicleHvacStatusData], native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, translation_key="outside_temperature", + value_lambda=lambda e: e.coordinator.data.externalTemperature, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleHvacStatusData]( key="hvac_soc_threshold", coordinator="hvac_status", - data_key="socThreshold", - entity_class=RenaultSensor[KamereonVehicleHvacStatusData], native_unit_of_measurement=PERCENTAGE, translation_key="hvac_soc_threshold", + value_lambda=lambda e: e.coordinator.data.socThreshold, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleHvacStatusData]( key="hvac_last_activity", coordinator="hvac_status", device_class=SensorDeviceClass.TIMESTAMP, - data_key="lastUpdateTime", - entity_class=RenaultSensor[KamereonVehicleHvacStatusData], entity_registry_enabled_default=False, translation_key="hvac_last_activity", - value_lambda=_get_utc_value, + value_lambda=lambda e: _get_utc_value(e.coordinator.data.lastUpdateTime), ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleLocationData]( key="location_last_activity", coordinator="location", device_class=SensorDeviceClass.TIMESTAMP, - data_key="lastUpdateTime", - entity_class=RenaultSensor[KamereonVehicleLocationData], entity_registry_enabled_default=False, translation_key="location_last_activity", - value_lambda=_get_utc_value, + value_lambda=lambda e: _get_utc_value(e.coordinator.data.lastUpdateTime), ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleResStateData]( key="res_state", coordinator="res_state", - data_key="details", - entity_class=RenaultSensor[KamereonVehicleResStateData], translation_key="res_state", + value_lambda=lambda e: e.coordinator.data.details, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleResStateData]( key="res_state_code", coordinator="res_state", - data_key="code", - entity_class=RenaultSensor[KamereonVehicleResStateData], entity_registry_enabled_default=False, translation_key="res_state_code", + value_lambda=lambda e: e.coordinator.data.code, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleChargingSettingsData]( key="charging_settings_mode", coordinator="charging_settings", - data_key="mode", translation_key="charging_settings_mode", - entity_class=RenaultSensor[KamereonVehicleChargingSettingsData], device_class=SensorDeviceClass.ENUM, options=[ "always", @@ -361,44 +320,40 @@ SENSOR_TYPES: tuple[RenaultSensorEntityDescription[Any], ...] = ( ], value_lambda=_get_charging_settings_mode_formatted, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleTyrePressureData]( key="front_left_pressure", coordinator="pressure", - data_key="flPressure", device_class=SensorDeviceClass.PRESSURE, - entity_class=RenaultSensor[KamereonVehicleTyrePressureData], native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, translation_key="front_left_pressure", + value_lambda=lambda e: e.coordinator.data.flPressure, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleTyrePressureData]( key="front_right_pressure", coordinator="pressure", - data_key="frPressure", device_class=SensorDeviceClass.PRESSURE, - entity_class=RenaultSensor[KamereonVehicleTyrePressureData], native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, translation_key="front_right_pressure", + value_lambda=lambda e: e.coordinator.data.frPressure, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleTyrePressureData]( key="rear_left_pressure", coordinator="pressure", - data_key="rlPressure", device_class=SensorDeviceClass.PRESSURE, - entity_class=RenaultSensor[KamereonVehicleTyrePressureData], native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, translation_key="rear_left_pressure", + value_lambda=lambda e: e.coordinator.data.rlPressure, ), - RenaultSensorEntityDescription( + RenaultSensorEntityDescription[KamereonVehicleTyrePressureData]( key="rear_right_pressure", coordinator="pressure", - data_key="rrPressure", device_class=SensorDeviceClass.PRESSURE, - entity_class=RenaultSensor[KamereonVehicleTyrePressureData], native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, translation_key="rear_right_pressure", + value_lambda=lambda e: e.coordinator.data.rrPressure, ), ) diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index a8811ff231b..97138133005 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -1,7 +1,5 @@ """Support for Renault services.""" -from __future__ import annotations - from datetime import datetime import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/renault/services.yaml b/homeassistant/components/renault/services.yaml index c9f4351a68c..98411f1733e 100644 --- a/homeassistant/components/renault/services.yaml +++ b/homeassistant/components/renault/services.yaml @@ -17,7 +17,7 @@ ac_start: when: example: "2020-05-01T17:45:00" selector: - text: + datetime: ac_cancel: fields: 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..e525503ee56 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 diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index e70a19c09e2..b63ce543f18 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -1,7 +1,5 @@ """Component providing support for Reolink binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass 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..5bda37038ee 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -1,7 +1,5 @@ """Component providing support for Reolink IP cameras.""" -from __future__ import annotations - from dataclasses import dataclass import logging diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 80d403c6e38..48bb3b59469 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 diff --git a/homeassistant/components/reolink/coordinator.py b/homeassistant/components/reolink/coordinator.py index 094039d57a3..5d5d1a7a72c 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 diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index 912427fa881..eba71f0a8ec 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 diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 6cdef5e4c32..fb91a4844aa 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -1,7 +1,5 @@ """Reolink parent entity class.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 7b7cc48c1dd..f12d6ffa822 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 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/media_source.py b/homeassistant/components/reolink/media_source.py index f716340e06e..ff3654fa120 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 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..5fb857ef726 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 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 6a707d6ff72..979154776a7 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -1042,7 +1042,7 @@ "title": "Reolink firmware update required" }, "https_webhook": { - "description": "Reolink products can not push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `{example_url}` where `{example_ip}` is the IP of the Home Assistant device", + "description": "Reolink products cannot push motion events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `{example_url}` where `{example_ip}` is the IP of the Home Assistant device", "title": "Reolink webhook URL uses HTTPS (SSL)" }, "password_too_long": { @@ -1054,7 +1054,7 @@ "title": "Reolink incompatible with global SSL certificate" }, "webhook_url": { - "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera can not reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `{example_url}` where `{example_ip}` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL. Using fast motion/AI state polling until the first ONVIF push is received.", + "description": "Did not receive initial ONVIF state from {name}. Most likely, the Reolink camera cannot reach the current (local) Home Assistant URL `{base_url}`, please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}) that points to Home Assistant. For example `{example_url}` where `{example_ip}` is the IP of the Home Assistant device. Also, make sure the Reolink camera can reach that URL. Using fast motion/AI state polling until the first ONVIF push is received.", "title": "Reolink webhook URL unreachable" } }, 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..83428f2fbad 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 diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index e633cbac64f..db9802eff13 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 diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py index 3a160ce3f8a..d12735c0329 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 diff --git a/homeassistant/components/repairs/__init__.py b/homeassistant/components/repairs/__init__.py index 8ee09c9ed3d..9a7b0579e6f 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 diff --git a/homeassistant/components/repairs/issue_handler.py b/homeassistant/components/repairs/issue_handler.py index 63da15b1ede..2a5ccb5f040 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 diff --git a/homeassistant/components/repairs/models.py b/homeassistant/components/repairs/models.py index afac8813d1e..f79e7d1e1fa 100644 --- a/homeassistant/components/repairs/models.py +++ b/homeassistant/components/repairs/models.py @@ -1,7 +1,5 @@ """Models for Repairs.""" -from __future__ import annotations - from typing import Protocol from homeassistant import data_entry_flow 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..9bb7bd967e6 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 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..2f5ae3b7410 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 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..b3452fd255e 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 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/switch.py b/homeassistant/components/rest/switch.py index d5d41f8b0a0..1affeb01d8d 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 diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index bf51fc2692d..da487df88da 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 diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index d83a242ac71..b32e58d7348 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 diff --git a/homeassistant/components/rflink/binary_sensor.py b/homeassistant/components/rflink/binary_sensor.py index 713dc02d6b8..53d68a7607b 100644 --- a/homeassistant/components/rflink/binary_sensor.py +++ b/homeassistant/components/rflink/binary_sensor.py @@ -1,7 +1,5 @@ """Support for Rflink binary sensors.""" -from __future__ import annotations - from typing import Any import voluptuous as vol diff --git a/homeassistant/components/rflink/const.py b/homeassistant/components/rflink/const.py index 83eb2915f70..a50b2aa0b6c 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 diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index 8b21bc9274d..7390a74e3a1 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -1,7 +1,5 @@ """Support for Rflink Cover devices.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/rflink/entity.py b/homeassistant/components/rflink/entity.py index fe9c5e6e4f2..46436782bd6 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 diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 24bbf06c049..67b4b03c83e 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 diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index 97d0b811509..7660a6aa9fb 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -1,7 +1,5 @@ """Support for Rflink sensors.""" -from __future__ import annotations - from typing import Any from rflink.parser import PACKET_FIELDS, UNITS diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py index bbbce2b8e9a..2bf4287aec7 100644 --- a/homeassistant/components/rflink/switch.py +++ b/homeassistant/components/rflink/switch.py @@ -1,7 +1,5 @@ """Support for Rflink switches.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.switch import ( diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 8692ff40366..a6c9aa39cc6 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=hass-use-runtime-data hass.data[DOMAIN][DATA_RFXOBJECT] = rfx_object entry.async_on_unload( 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..f01d46c946d 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=hass-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..ad284fcfe52 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 @@ -119,5 +117,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=hass-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..44cb54c8563 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 diff --git a/homeassistant/components/rfxtrx/siren.py b/homeassistant/components/rfxtrx/siren.py index 1164dafbfce..77eeee15762 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 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..6c0d20af5d0 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 = [] @@ -66,11 +62,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..310858c50e2 100644 --- a/homeassistant/components/ridwell/entity.py +++ b/homeassistant/components/ridwell/entity.py @@ -1,7 +1,5 @@ """Define a base Ridwell entity.""" -from __future__ import annotations - from datetime import date from aioridwell.model import RidwellAccount, RidwellPickupEvent diff --git a/homeassistant/components/ridwell/sensor.py b/homeassistant/components/ridwell/sensor.py index 30f97ecaea8..578e83fd18a 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) diff --git a/homeassistant/components/ridwell/switch.py b/homeassistant/components/ridwell/switch.py index e3be9ea5368..98847b325f8 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) 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..987745b4c78 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 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 ee4ab050aca..0f466563caf 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 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..4734bf68f81 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, @@ -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/sensor.py b/homeassistant/components/ring/sensor.py index 2741e9d1b38..1cecdafa3fa 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 diff --git a/homeassistant/components/ring/strings.json b/homeassistant/components/ring/strings.json index 1159a8b906e..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" 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..2de9ef2c942 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -1,7 +1,5 @@ """The Risco integration.""" -from __future__ import annotations - import logging from pyrisco import CannotConnectError, RiscoCloud, RiscoLocal, UnauthorizedError @@ -26,15 +24,13 @@ from homeassistant.helpers.typing import ConfigType from .const import ( 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) @@ -58,7 +54,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,7 +62,9 @@ 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( @@ -120,14 +118,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 +142,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 +155,16 @@ 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] - await local_data.system.disconnect() - - hass.data[DOMAIN].pop(entry.entry_id) + if unload_ok and (local_data := entry.runtime_data.local_data): + await local_data.system.disconnect() 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..2d1ed5bb4f2 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,6 +31,7 @@ from .const import ( ) from .coordinator import RiscoDataUpdateCoordinator from .entity import RiscoCloudEntity +from .models import RiscoConfigEntry _LOGGER = logging.getLogger(__name__) @@ -49,13 +45,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 +63,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..67e9c40b0f8 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, @@ -42,6 +35,7 @@ from .const import ( RISCO_STATES, TYPE_LOCAL, ) +from .models import RiscoConfigEntry _LOGGER = logging.getLogger(__name__) @@ -121,12 +115,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,7 +212,7 @@ 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} @@ -238,6 +232,8 @@ class RiscoOptionsFlowHandler(OptionsFlow): self._data = {**DEFAULT_ADVANCED_OPTIONS, **self._data} schema = schema.extend( { + # Polling interval is user-configurable, which is no longer allowed + # pylint: disable-next=hass-config-flow-polling-field vol.Required( CONF_SCAN_INTERVAL, default=self._data[CONF_SCAN_INTERVAL] ): int, 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..75fa1261e34 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.6.8"] } 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/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..44bfe044a28 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,7 +27,7 @@ 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 if CONF_EMAIL not in entry.data or CONF_PASSWORD not in entry.data: @@ -87,19 +87,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..c65699b73fc 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.""" - config_entry: ConfigEntry + config_entry: RitualsConfigEntry def __init__( self, 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 aa468570b04..4b345db353e 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 @@ -30,6 +28,8 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_BASE_URL, CONF_SHOW_BACKGROUND, + CONF_SHOW_ROOMS, + CONF_SHOW_WALLS, CONF_USER_DATA, DEFAULT_DRAWABLES, DOMAIN, @@ -87,6 +87,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> if entry.options.get(DRAWABLES, {}).get(drawable, default_value) ], show_background=entry.options.get(CONF_SHOW_BACKGROUND, False), + show_rooms=entry.options.get(CONF_SHOW_ROOMS, True), + show_walls=entry.options.get(CONF_SHOW_WALLS, True), map_scale=MAP_SCALE, ), mqtt_session_unauthorized_hook=lambda: entry.async_start_reauth(hass), diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index b79128e809c..24a4ef7b8bb 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 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 3cf0848ca45..70959116273 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Roborock.""" -from __future__ import annotations - from collections.abc import Mapping from copy import deepcopy import logging @@ -42,6 +40,8 @@ from .const import ( CONF_ENTRY_CODE, CONF_REGION, CONF_SHOW_BACKGROUND, + CONF_SHOW_ROOMS, + CONF_SHOW_WALLS, CONF_USER_DATA, DEFAULT_DRAWABLES, DOMAIN, @@ -246,6 +246,8 @@ class RoborockOptionsFlowHandler(OptionsFlowWithReload): """Manage the map object drawable options.""" if user_input is not None: self.options[CONF_SHOW_BACKGROUND] = user_input.pop(CONF_SHOW_BACKGROUND) + self.options[CONF_SHOW_ROOMS] = user_input.pop(CONF_SHOW_ROOMS) + self.options[CONF_SHOW_WALLS] = user_input.pop(CONF_SHOW_WALLS) self.options.setdefault(DRAWABLES, {}).update(user_input) return self.async_create_entry(title="", data=self.options) data_schema = {} @@ -264,6 +266,18 @@ class RoborockOptionsFlowHandler(OptionsFlowWithReload): default=self.config_entry.options.get(CONF_SHOW_BACKGROUND, False), ) ] = bool + data_schema[ + vol.Required( + CONF_SHOW_ROOMS, + default=self.config_entry.options.get(CONF_SHOW_ROOMS, True), + ) + ] = bool + data_schema[ + vol.Required( + CONF_SHOW_WALLS, + default=self.config_entry.options.get(CONF_SHOW_WALLS, True), + ) + ] = bool return self.async_show_form( step_id=DRAWABLES, data_schema=vol.Schema(data_schema), diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 9393b58d6d9..1ed0df695b8 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -11,8 +11,11 @@ CONF_ENTRY_CODE = "code" CONF_BASE_URL = "base_url" 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"] + # Option Flow steps DRAWABLES = "drawables" diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 146ba965365..3f793ed2c7f 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -1,7 +1,5 @@ """Roborock Coordinator.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import datetime, timedelta import logging @@ -550,6 +548,7 @@ class RoborockB01Q7UpdateCoordinator(RoborockDataUpdateCoordinatorB01): RoborockB01Props.WIND, RoborockB01Props.WATER, RoborockB01Props.MODE, + RoborockB01Props.CLEAN_PATH_PREFERENCE, RoborockB01Props.QUANTITY, ] @@ -608,8 +607,9 @@ class RoborockB01Q10UpdateCoordinator(DataUpdateCoordinator[None]): async def _async_update_data(self) -> None: """Request a status push from the device. - This sends a fire-and-forget REQUEST_DPS command. The actual data - update will arrive asynchronously via the push listener. + This coordinator does not wait for any specific MQTT payload because + push messages are asynchronous and not guaranteed to contain every + field. Entities subscribe to trait updates and update as values arrive. """ try: await self.api.refresh() 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 f6053090bb7..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" }, @@ -40,6 +46,9 @@ } }, "sensor": { + "brush_remaining": { + "default": "mdi:brush" + }, "clean_percent": { "default": "mdi:progress-check" }, @@ -49,6 +58,9 @@ "cleaning_brush_time_left": { "default": "mdi:brush" }, + "cleaning_time": { + "default": "mdi:clock-outline" + }, "countdown": { "default": "mdi:clock-outline" }, @@ -67,6 +79,12 @@ "main_brush_time_left": { "default": "mdi:brush" }, + "mop_drying_remaining_time": { + "default": "mdi:clock-outline" + }, + "mop_life_time_left": { + "default": "mdi:texture" + }, "sensor_time_left": { "default": "mdi:eye-outline" }, @@ -79,6 +97,9 @@ "strainer_time_left": { "default": "mdi:filter-variant" }, + "times_after_clean": { + "default": "mdi:counter" + }, "total_cleaning_area": { "default": "mdi:texture-box" }, diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 04f4fbfa29a..49f08024092 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.0.0", + "python-roborock==5.5.1", "vacuum-map-parser-roborock==0.1.4" ] } diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 0ff27d8145f..2ba3d611bb3 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, ) @@ -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): @@ -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 48f5407f86c..302ec232273 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 @@ -19,6 +17,8 @@ from roborock.data import ( ZeoError, ZeoState, ) +from roborock.data.b01_q10.b01_q10_code_mappings import YXDeviceState +from roborock.devices.traits.b01.q10.status import StatusTrait as Q10StatusTrait from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol from homeassistant.components.sensor import ( @@ -34,6 +34,7 @@ from homeassistant.helpers.typing import StateType from .coordinator import ( RoborockB01Q7UpdateCoordinator, + RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, @@ -43,6 +44,7 @@ from .coordinator import ( from .entity import ( RoborockCoordinatedEntityA01, RoborockCoordinatedEntityB01Q7, + RoborockCoordinatedEntityB01Q10, RoborockCoordinatedEntityV1, RoborockEntity, ) @@ -77,6 +79,13 @@ class RoborockSensorDescriptionB01(SensorEntityDescription): value_fn: Callable[[B01Props], StateType] +@dataclass(frozen=True, kw_only=True) +class RoborockSensorDescriptionQ10(SensorEntityDescription): + """A class that describes Roborock Q10 sensors.""" + + value_fn: Callable[[Q10StatusTrait], StateType] + + def _dock_error_value_fn(state: DeviceState) -> str | None: if ( status := state.status.dock_error_status @@ -246,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", @@ -412,6 +422,105 @@ Q7_B01_SENSOR_DESCRIPTIONS = [ ] +Q10_B01_SENSOR_DESCRIPTIONS = [ + RoborockSensorDescriptionQ10( + key="status", + translation_key="status", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda data: data.status.value if data.status is not None else None, + entity_category=EntityCategory.DIAGNOSTIC, + options=YXDeviceState.keys(), + ), + RoborockSensorDescriptionQ10( + key="battery", + value_fn=lambda data: data.battery, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), + RoborockSensorDescriptionQ10( + key="cleaning_time", + translation_key="cleaning_time", + value_fn=lambda data: data.clean_time, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + ), + RoborockSensorDescriptionQ10( + key="cleaning_area", + translation_key="cleaning_area", + value_fn=lambda data: data.clean_area, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, + ), + RoborockSensorDescriptionQ10( + key="total_cleaning_count", + translation_key="total_cleaning_count", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.total_clean_count, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescriptionQ10( + key="total_cleaning_area", + translation_key="total_cleaning_area", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.total_clean_area, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, + ), + RoborockSensorDescriptionQ10( + key="total_cleaning_time", + translation_key="total_cleaning_time", + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda data: data.total_clean_time, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.MINUTES, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + RoborockSensorDescriptionQ10( + key="main_brush_life", + translation_key="main_brush_life", + value_fn=lambda data: data.main_brush_life, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + RoborockSensorDescriptionQ10( + key="side_brush_life", + translation_key="side_brush_life", + value_fn=lambda data: data.side_brush_life, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + RoborockSensorDescriptionQ10( + key="filter_life", + translation_key="filter_life", + value_fn=lambda data: data.filter_life, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + RoborockSensorDescriptionQ10( + key="sensor_life", + translation_key="sensor_life", + value_fn=lambda data: data.sensor_life, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + ), + RoborockSensorDescriptionQ10( + key="clean_percent", + translation_key="clean_percent", + value_fn=lambda data: data.cleaning_progress, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: RoborockConfigEntry, @@ -460,6 +569,11 @@ async def async_setup_entry( for description in Q7_B01_SENSOR_DESCRIPTIONS if description.value_fn(coordinator.data) is not None ) + entities.extend( + RoborockSensorEntityB01Q10(coordinator, description) + for coordinator in coordinators.b01_q10 + for description in Q10_B01_SENSOR_DESCRIPTIONS + ) async_add_entities(entities) @@ -568,3 +682,30 @@ class RoborockSensorEntityB01Q7(RoborockCoordinatedEntityB01Q7, SensorEntity): def native_value(self) -> StateType: """Return the value reported by the sensor.""" return self.entity_description.value_fn(self.coordinator.data) + + +class RoborockSensorEntityB01Q10(RoborockCoordinatedEntityB01Q10, SensorEntity): + """Representation of a B01 Q10 Roborock sensor.""" + + entity_description: RoborockSensorDescriptionQ10 + + def __init__( + self, + coordinator: RoborockB01Q10UpdateCoordinator, + description: RoborockSensorDescriptionQ10, + ) -> None: + """Initialize the entity.""" + self.entity_description = description + super().__init__(f"{description.key}_{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 native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.api.status) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 66bff33c510..dcd09fe973f 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -93,6 +93,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" }, @@ -103,7 +109,7 @@ "name": "Reset side brush consumable" }, "shutdown": { - "name": "Shutdown" + "name": "Shut down" }, "start": { "name": "Start" @@ -123,6 +129,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": { @@ -368,6 +381,9 @@ "water_empty": "Water empty" } }, + "filter_life": { + "name": "Filter time used" + }, "filter_time_left": { "name": "Filter time left" }, @@ -377,6 +393,9 @@ "last_clean_start": { "name": "Last clean begin" }, + "main_brush_life": { + "name": "Main brush time used" + }, "main_brush_time_left": { "name": "Main brush time left" }, @@ -402,9 +421,15 @@ "waiting_for_orders": "Waiting for orders" } }, + "sensor_life": { + "name": "Sensor time used" + }, "sensor_time_left": { "name": "Sensor time left" }, + "side_brush_life": { + "name": "Side brush time used" + }, "side_brush_time_left": { "name": "Side brush time left" }, @@ -430,15 +455,23 @@ "locked": "Locked", "manual_mode": "Manual mode", "mapping": "Mapping", + "mopping": "Mopping", "paused": "[%key:common::state::paused%]", + "relocating": "Relocating", "remote_control_active": "Remote control active", "returning_home": "Returning home", + "saving_map": "Saving map", "segment_cleaning": "Segment cleaning", "shutting_down": "Shutting down", + "sleeping": "Sleeping", "spot_cleaning": "Spot cleaning", "starting": "Starting", + "sweep_and_mop": "Sweep and mop", + "sweeping": "Sweeping", + "transitioning": "Transitioning", "unknown": "Unknown", "updating": "Updating", + "waiting_to_charge": "Waiting to charge", "washing_the_mop": "Washing the mop", "zoned_cleaning": "Zoned cleaning" } @@ -461,10 +494,14 @@ "vacuum_error": { "name": "Vacuum error", "state": { + "audio_error": "Audio error", "battery_error": "Battery error", "bumper_stuck": "Bumper stuck", "cannot_cross_carpet": "Cannot cross carpet", "charging_error": "Charging error", + "check_clean_carouse": "Check the cleaning carousel", + "clean_carousel_exception": "Cleaning carousel error", + "clean_carousel_water_full": "Cleaning carousel water full", "clear_brush_exception": "Check that the water filter has been correctly installed", "clear_brush_exception_2": "Positioning button error", "clear_water_box_exception": "Clean water tank empty", @@ -476,6 +513,7 @@ "dirty_water_box_hoare": "Check the dirty water tank", "dock": "Dock not connected to power", "dock_locator_error": "Dock locator error", + "drain_water_exception": "Drain water exception", "fan_error": "Fan error", "filter_blocked": "Filter blocked", "filter_screen_exception": "Clean the dock water filter", @@ -491,6 +529,7 @@ "no_dustbin": "No dustbin", "nogo_zone_detected": "No-go zone detected", "none": "None", + "optical_flow_sensor_dirt": "Optical flow sensor dirty", "return_to_dock_fail": "Return to dock fail", "robot_on_carpet": "Robot on carpet", "robot_tilted": "Robot tilted", @@ -500,10 +539,12 @@ "sink_strainer_hoare": "Reinstall the water filter", "strainer_error": "Filter is wet or blocked", "temperature_protection": "Unit temperature protection", + "up_water_exception": "Water supply exception", "vertical_bumper_pressed": "Vertical bumper pressed", "vibrarise_jammed": "VibraRise jammed", "visual_sensor": "Camera error", "wall_sensor_dirty": "Wall sensor dirty", + "water_carriage_drop": "Water carriage dropped", "wheels_jammed": "Wheels jammed", "wheels_suspended": "Wheels suspended" } @@ -686,6 +727,8 @@ "predicted_path": "Predicted path", "room_names": "Room names", "show_background": "Show background", + "show_rooms": "Show rooms", + "show_walls": "Show walls", "vacuum_position": "Vacuum position", "virtual_walls": "Virtual walls", "zones": "Zones" @@ -706,6 +749,8 @@ "predicted_path": "Show the predicted path on the map.", "room_names": "Show room names on the map.", "show_background": "Add a background to the map.", + "show_rooms": "Show the rooms on the map.", + "show_walls": "Show the walls on the map.", "vacuum_position": "Show the vacuum position on the map.", "virtual_walls": "Show virtual walls on the map.", "zones": "Show zones on the map." diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py index 27f901740ec..de17cee7f4b 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 diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 623644379a9..cda6c8fe781 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -19,7 +19,11 @@ from homeassistant.components.vacuum import ( VacuumEntityFeature, ) from homeassistant.core import HomeAssistant, ServiceResponse, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + HomeAssistantError, + ServiceNotSupported, + ServiceValidationError, +) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN @@ -236,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.""" @@ -454,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, @@ -484,6 +498,18 @@ class RoborockQ7Vacuum(RoborockCoordinatedEntityB01Q7, StateVacuumEntity): }, ) from err + async def get_maps(self) -> ServiceResponse: + """Get map information such as map id and room ids.""" + raise ServiceNotSupported(DOMAIN, "get_maps", self.entity_id) + + async def get_vacuum_current_position(self) -> ServiceResponse: + """Get the current position of the vacuum from the map.""" + raise ServiceNotSupported(DOMAIN, "get_vacuum_current_position", self.entity_id) + + async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: + """Set the vacuum to go to a specific position.""" + raise ServiceNotSupported(DOMAIN, "set_vacuum_goto_position", self.entity_id) + class RoborockQ10Vacuum(RoborockCoordinatedEntityB01Q10, StateVacuumEntity): """Representation of a Roborock Q10 vacuum.""" @@ -654,3 +680,15 @@ class RoborockQ10Vacuum(RoborockCoordinatedEntityB01Q10, StateVacuumEntity): "command": command, }, ) from err + + async def get_maps(self) -> ServiceResponse: + """Get map information such as map id and room ids.""" + raise ServiceNotSupported(DOMAIN, "get_maps", self.entity_id) + + async def get_vacuum_current_position(self) -> ServiceResponse: + """Get the current position of the vacuum from the map.""" + raise ServiceNotSupported(DOMAIN, "get_vacuum_current_position", self.entity_id) + + async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: + """Set the vacuum to go to a specific position.""" + raise ServiceNotSupported(DOMAIN, "set_vacuum_goto_position", self.entity_id) diff --git a/homeassistant/components/rocketchat/notify.py b/homeassistant/components/rocketchat/notify.py index 96eda4b5609..9c5c07897c4 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 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..b185a2f421a 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 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..0908e41f437 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 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..aaa2c896373 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 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..ed0c3cc6ee3 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 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/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_player.py b/homeassistant/components/roon/media_player.py index 804fb0244b5..1973baf0d2b 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 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/route_b_smart_meter/quality_scale.yaml b/homeassistant/components/route_b_smart_meter/quality_scale.yaml index f6123b6e4c9..7e8f13e05a8 100644 --- a/homeassistant/components/route_b_smart_meter/quality_scale.yaml +++ b/homeassistant/components/route_b_smart_meter/quality_scale.yaml @@ -4,8 +4,7 @@ rules: status: exempt comment: | The integration does not provide any additional actions. - appropriate-polling: - status: done + appropriate-polling: done brands: status: exempt comment: | 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/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..8640e6c25b3 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 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 588f1396036..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.9.1"], + "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..d60c212ff27 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 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..48b87b249bd 100644 --- a/homeassistant/components/ruuvitag_ble/config_flow.py +++ b/homeassistant/components/ruuvitag_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for ruuvitag_ble.""" -from __future__ import annotations - from typing import Any from ruuvitag_ble import RuuvitagBluetoothDeviceData diff --git a/homeassistant/components/ruuvitag_ble/sensor.py b/homeassistant/components/ruuvitag_ble/sensor.py index 0b359a570eb..057b022b3dc 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=hass-use-runtime-data coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] 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..8881267d490 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 diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index f7af5efc899..449c0722bde 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 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..f88d902668b 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 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..d647ab85286 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 ( 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..a788ecae157 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 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/media_player.py b/homeassistant/components/samsungtv/media_player.py index 56eb0abd9f5..b86450b0396 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 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..90896a0011a 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." } } }, 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..3c75c00eb2d 100644 --- a/homeassistant/components/satel_integra/__init__.py +++ b/homeassistant/components/satel_integra/__init__.py @@ -9,6 +9,7 @@ from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_e from .client import SatelClient from .const import ( + CONF_ENCRYPTION_KEY, CONF_OUTPUT_NUMBER, CONF_PARTITION_NUMBER, CONF_SWITCHABLE_OUTPUT_NUMBER, @@ -43,6 +44,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: SatelConfigEntry) -> boo coordinator_outputs = SatelIntegraOutputsCoordinator(hass, entry, client) coordinator_partitions = SatelIntegraPartitionsCoordinator(hass, entry, client) + for coordinator in ( + coordinator_zones, + coordinator_outputs, + coordinator_partitions, + ): + coordinator.setup() + await client.async_connect( coordinator_zones.zones_update_callback, coordinator_outputs.outputs_update_callback, @@ -139,6 +147,14 @@ 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 + ) + _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..e5bbd2b11db 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 @@ -105,13 +103,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..cc238ae980c 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, diff --git a/homeassistant/components/satel_integra/client.py b/homeassistant/components/satel_integra/client.py index db66d8af6fa..d9124f1b0d5 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, @@ -61,7 +68,14 @@ class SatelClient: 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 +84,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..83b907986f1 100644 --- a/homeassistant/components/satel_integra/config_flow.py +++ b/homeassistant/components/satel_integra/config_flow.py @@ -1,11 +1,14 @@ """Config flow for Satel Integra.""" -from __future__ import annotations - 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 +27,7 @@ from homeassistant.helpers import config_validation as cv, selector from .const import ( CONF_ARM_HOME_MODE, + CONF_ENCRYPTION_KEY, CONF_OUTPUT_NUMBER, CONF_PARTITION_NUMBER, CONF_SWITCHABLE_OUTPUT_NUMBER, @@ -45,6 +49,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) + ), } ) @@ -91,7 +98,7 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN): self.connection_data: dict[str, Any] = {} VERSION = 2 - MINOR_VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback @@ -123,15 +130,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 +176,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 +213,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.""" diff --git a/homeassistant/components/satel_integra/const.py b/homeassistant/components/satel_integra/const.py index 33e9c7a9572..a444bff9aca 100644 --- a/homeassistant/components/satel_integra/const.py +++ b/homeassistant/components/satel_integra/const.py @@ -17,3 +17,4 @@ CONF_SWITCHABLE_OUTPUT_NUMBER = "switchable_output_number" CONF_ARM_HOME_MODE = "arm_home_mode" CONF_ZONE_TYPE = "type" +CONF_ENCRYPTION_KEY = "encryption_key" diff --git a/homeassistant/components/satel_integra/coordinator.py b/homeassistant/components/satel_integra/coordinator.py index 19101ba3ec4..fdcf832b5e5 100644 --- a/homeassistant/components/satel_integra/coordinator.py +++ b/homeassistant/components/satel_integra/coordinator.py @@ -1,7 +1,5 @@ """Coordinator for Satel Integra.""" -from __future__ import annotations - from dataclasses import dataclass import logging @@ -50,6 +48,17 @@ class SatelIntegraBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): name=f"{entry.entry_id} {self.__class__.__name__}", ) + 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.""" 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..208f2693470 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 @@ -65,3 +63,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 653051e8514..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.0.0"] + "requirements": ["satel-integra==1.3.1"] } diff --git a/homeassistant/components/satel_integra/strings.json b/homeassistant/components/satel_integra/strings.json index 67fe3b94101..53d64e4f9e6 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" } @@ -180,8 +189,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..bf610c2d62c 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 diff --git a/homeassistant/components/saunum/__init__.py b/homeassistant/components/saunum/__init__.py index 6248ac8dd72..db45b86e11e 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 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/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..7fcd2df56fd 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 @@ -390,7 +388,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 bb51bd39dc0..00abdc22f49 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted schedules.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted schedules to trigger on.", - "trigger_behavior_name": "Behavior" + "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 schedule blocks are currently not active.", "fields": { "behavior": { - "description": "[%key:component::schedule::common::condition_behavior_description%]", "name": "[%key:component::schedule::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::schedule::common::condition_for_name%]" } }, "name": "Schedule is off" @@ -20,8 +22,10 @@ "description": "Tests if one or more schedule blocks are currently active.", "fields": { "behavior": { - "description": "[%key:component::schedule::common::condition_behavior_description%]", "name": "[%key:component::schedule::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::schedule::common::condition_for_name%]" } }, "name": "Schedule is on" @@ -48,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.", @@ -79,8 +68,10 @@ "description": "Triggers when a schedule block ends.", "fields": { "behavior": { - "description": "[%key:component::schedule::common::trigger_behavior_description%]", "name": "[%key:component::schedule::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::schedule::common::trigger_for_name%]" } }, "name": "Schedule block ended" @@ -89,8 +80,10 @@ "description": "Triggers when a schedule block starts.", "fields": { "behavior": { - "description": "[%key:component::schedule::common::trigger_behavior_description%]", "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..247b0fc05e0 100644 --- a/homeassistant/components/schedule/triggers.yaml +++ b/homeassistant/components/schedule/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any 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 ed995d4aa3d..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,8 @@ 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/lock.py b/homeassistant/components/schlage/lock.py index 739e5a0b1d7..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 @@ -113,14 +111,14 @@ class SchlageLockEntity(SchlageEntity, LockEntity): ) from ex return self._lock.access_codes - async def add_code(self, name: str, code: str) -> None: + async def add_code(self, name: str, code: str, notify_on_use: bool = True) -> None: """Add a lock code.""" codes = await self._async_fetch_access_codes() self._validate_code_name(codes, name) self._validate_code_value(codes, code) - access_code = AccessCode(name=name, code=code) + access_code = AccessCode(name=name, code=code, notify_on_use=notify_on_use) try: await self.hass.async_add_executor_job( self._lock.add_access_code, access_code 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/services.yaml b/homeassistant/components/schlage/services.yaml index 97412251ea4..e248e8c6e8d 100644 --- a/homeassistant/components/schlage/services.yaml +++ b/homeassistant/components/schlage/services.yaml @@ -23,6 +23,11 @@ add_code: text: multiline: false type: password + notify_on_use: + required: false + default: true + selector: + boolean: delete_code: target: diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index 48f0232eb75..0e19b66cf82 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -83,6 +83,10 @@ "name": { "description": "Name for PIN code. Must be case insensitively unique to the lock.", "name": "PIN name" + }, + "notify_on_use": { + "description": "Whether the native Schlage notification should be sent when this PIN is used.", + "name": "Notify when PIN is used" } }, "name": "Add PIN code" 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..661b6d8fb64 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, diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 5c39b57f785..07838090523 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,159 @@ 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..6fc2e85cd18 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -1,13 +1,12 @@ """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 DEFAULT_TIMEOUT, @@ -18,10 +17,20 @@ from homeassistant.components.rest.schema import ( # pylint: disable=hass-compo ) 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,242 @@ 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( + {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..f7bfaee56e4 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 diff --git a/homeassistant/components/scrape/icons.json b/homeassistant/components/scrape/icons.json new file mode 100644 index 00000000000..dfe38ca34cc --- /dev/null +++ b/homeassistant/components/scrape/icons.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "sections": { + "advanced": "mdi:cog", + "auth": "mdi:lock" + } + } + } + }, + "options": { + "step": { + "init": { + "sections": { + "advanced": "mdi:cog" + } + } + } + } +} diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index c6682fba5a8..0bab001c8c7 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -1,7 +1,5 @@ """Support for getting data from websites with scraping.""" -from __future__ import annotations - import logging from typing import Any, cast @@ -46,9 +44,10 @@ TRIGGER_ENTITY_OPTIONS = ( CONF_AVAILABILITY, CONF_DEVICE_CLASS, CONF_ICON, + CONF_NAME, CONF_PICTURE, - CONF_UNIQUE_ID, CONF_STATE_CLASS, + CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, ) @@ -70,7 +69,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 +97,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 +123,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/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index b4deb9b36aa..f8bc2cce017 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=hass-config-flow-polling-field vol.Required( CONF_SCAN_INTERVAL, default=current_interval, 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..514008aa1e4 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 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 bc1feaccbf4..d6719808a99 100644 --- a/homeassistant/components/select/conditions.yaml +++ b/homeassistant/components/select/conditions.yaml @@ -8,18 +8,19 @@ 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 required: true selector: state: - attribute: options hide_states: - unavailable - unknown 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 cac07327f53..2a2c894eca7 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -4,8 +4,10 @@ "description": "Tests if one or more dropdowns have a specific option selected.", "fields": { "behavior": { - "description": "Whether the condition should pass when any or all targeted entities match.", - "name": "Behavior" + "name": "Condition passes if" + }, + "for": { + "name": "For at least" }, "option": { "description": "The options to check for.", @@ -52,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/coordinator.py b/homeassistant/components/sense/coordinator.py index 1957352aea6..e009b2b6378 100644 --- a/homeassistant/components/sense/coordinator.py +++ b/homeassistant/components/sense/coordinator.py @@ -1,7 +1,5 @@ """Sense Coordinators.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import TYPE_CHECKING 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..6c50d5e2d26 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 diff --git a/homeassistant/components/sensirion_ble/config_flow.py b/homeassistant/components/sensirion_ble/config_flow.py index 82cf5ebbeea..91dd077a9b4 100644 --- a/homeassistant/components/sensirion_ble/config_flow.py +++ b/homeassistant/components/sensirion_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for sensirion_ble.""" -from __future__ import annotations - from typing import Any from sensirion_ble import SensirionBluetoothDeviceData diff --git a/homeassistant/components/sensirion_ble/sensor.py b/homeassistant/components/sensirion_ble/sensor.py index 16f7571f392..e1f73251ac1 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, @@ -109,6 +107,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sensirion BLE sensors.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 3148b0d13c2..de6e2355c20 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( @@ -610,10 +627,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,10 +648,13 @@ 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"Invalid datetime: {self.entity_id} has {device_class.value} device class " f"but provides state {value}:{type(value)} resulting in '{err}'" ) from err diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 0a7fac21576..b59d32909ee 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. @@ -180,7 +193,7 @@ class SensorDeviceClass(StrEnum): CURRENT = "current" """Current. - Unit of measurement: `A`, `mA` + Unit of measurement: `A`, `mA`, `μA` """ DATA_RATE = "data_rate" @@ -238,7 +251,7 @@ class SensorDeviceClass(StrEnum): FREQUENCY = "frequency" """Frequency. - Unit of measurement: `Hz`, `kHz`, `MHz`, `GHz` + Unit of measurement: `mHz`, `Hz`, `kHz`, `MHz`, `GHz` """ GAS = "gas" @@ -515,6 +528,7 @@ NON_NUMERIC_DEVICE_CLASSES = { SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorDeviceClass)) @@ -565,6 +579,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 +829,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..796d876db83 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 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..17a755df931 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=hass-use-runtime-data coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] 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..7fe66e3b483 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 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/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..b87fc694918 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.7.1"] } 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 90fe9f325fa..6a3e83e7bb2 100644 --- a/homeassistant/components/seventeentrack/__init__.py +++ b/homeassistant/components/seventeentrack/__init__.py @@ -3,16 +3,15 @@ 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 from homeassistant.helpers import config_validation as cv -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 -from .coordinator import SeventeenTrackCoordinator +from .coordinator import SeventeenTrackConfigEntry, SeventeenTrackCoordinator from .services import async_setup_services PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -28,10 +27,12 @@ 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_get_clientsession(hass) + session = async_create_clientsession(hass) client = SeventeenTrackClient(session=session) try: @@ -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 f4f3b3e82ae..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) @@ -99,5 +98,5 @@ class SeventeenTrackConfigFlow(ConfigFlow, domain=DOMAIN): @callback def _get_client(self): - session = aiohttp_client.async_get_clientsession(self.hass) + session = aiohttp_client.async_create_clientsession(self.hass) return SeventeenTrackClient(session=session) 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..9dbc956bdb1 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 diff --git a/homeassistant/components/sftp_storage/backup.py b/homeassistant/components/sftp_storage/backup.py index 2367d022a44..e5cacabc6b8 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 diff --git a/homeassistant/components/sftp_storage/client.py b/homeassistant/components/sftp_storage/client.py index 246862f8551..1ba9960e961 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 diff --git a/homeassistant/components/sftp_storage/config_flow.py b/homeassistant/components/sftp_storage/config_flow.py index cecd7d54b35..30683e2eb9d 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 diff --git a/homeassistant/components/sftp_storage/const.py b/homeassistant/components/sftp_storage/const.py index aa582760be8..c4cac7d19b6 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 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..376e1d8e328 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 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/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 3856bf73554..b43f458ccb5 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( diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index 842dc74ea5a..3c25b024347 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -1,14 +1,16 @@ """Expose regular shell commands as services.""" -from __future__ import annotations - import asyncio +from collections.abc import Callable, Coroutine from contextlib import suppress import logging import shlex +from typing import Any import voluptuous as vol +import homeassistant.config as conf_util +from homeassistant.const import SERVICE_RELOAD from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -16,7 +18,12 @@ from homeassistant.core import ( SupportsResponse, ) from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers import ( + config_validation as cv, + issue_registry as ir, + service as service_helper, + template, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonObjectType @@ -31,16 +38,14 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the shell_command component.""" - conf = config.get(DOMAIN, {}) - - cache: dict[str, tuple[str, str | None, template.Template | None]] = {} +def _make_handler( + cmd: str, + hass: HomeAssistant, + cache: dict[str, tuple[str, str | None, template.Template | None]], +) -> Callable[[ServiceCall], Coroutine[Any, Any, ServiceResponse]]: + """Return a service handler that executes the given shell command.""" async def async_service_handler(service: ServiceCall) -> ServiceResponse: - """Execute a shell command service.""" - cmd = conf[service.service] - if cmd in cache: prog, args, args_compiled = cache[cmd] elif " " not in cmd: @@ -66,7 +71,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if rendered_args == args: # No template used. default behavior - create_process = asyncio.create_subprocess_shell( cmd, stdin=None, @@ -78,7 +82,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Template used. Break into list and use create_subprocess_exec # (which uses shell=False) for security shlexed_cmd = [prog, *shlex.split(rendered_args)] - create_process = asyncio.create_subprocess_exec( *shlexed_cmd, stdin=None, @@ -153,11 +156,81 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return service_response return None - for name in conf: + return async_service_handler + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the shell_command component.""" + conf = config.get(DOMAIN, {}) + + cache: dict[str, tuple[str, str | None, template.Template | None]] = {} + + for name, command in conf.items(): + if name == SERVICE_RELOAD: + ir.async_create_issue( + hass, + DOMAIN, + f"reserved_{SERVICE_RELOAD}", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="reserved_reload_name", + translation_placeholders={"name": name}, + ) + _LOGGER.warning("Skipping shell_command entry '%s': name is reserved", name) + continue hass.services.async_register( DOMAIN, name, - async_service_handler, + _make_handler(command, hass, cache), supports_response=SupportsResponse.OPTIONAL, ) + + async def reload_service_handler(service_call: ServiceCall) -> None: + """Reload shell_command from YAML configuration.""" + try: + raw_config = await conf_util.async_hass_config_yaml(hass) + except HomeAssistantError as err: + _LOGGER.error("Error loading configuration.yaml: %s", err) + return + + try: + new_conf = CONFIG_SCHEMA(raw_config).get(DOMAIN, {}) + except vol.Invalid as err: + _LOGGER.error("Invalid shell_command configuration: %s", err) + return + + for svc in list(hass.services.async_services_for_domain(DOMAIN)): + if svc != SERVICE_RELOAD: + hass.services.async_remove(DOMAIN, svc) + cache.clear() + ir.async_delete_issue(hass, DOMAIN, f"reserved_{SERVICE_RELOAD}") + for name, command in new_conf.items(): + if name == SERVICE_RELOAD: + ir.async_create_issue( + hass, + DOMAIN, + f"reserved_{SERVICE_RELOAD}", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="reserved_reload_name", + translation_placeholders={"name": name}, + ) + _LOGGER.warning( + "Skipping shell_command entry '%s': name is reserved", name + ) + continue + hass.services.async_register( + DOMAIN, + name, + _make_handler(command, hass, cache), + supports_response=SupportsResponse.OPTIONAL, + ) + + service_helper.async_register_admin_service( + hass, + DOMAIN, + SERVICE_RELOAD, + reload_service_handler, + ) + return True diff --git a/homeassistant/components/shell_command/icons.json b/homeassistant/components/shell_command/icons.json new file mode 100644 index 00000000000..a9829425570 --- /dev/null +++ b/homeassistant/components/shell_command/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "reload": { + "service": "mdi:reload" + } + } +} diff --git a/homeassistant/components/shell_command/services.yaml b/homeassistant/components/shell_command/services.yaml index df056f94e85..c983a105c93 100644 --- a/homeassistant/components/shell_command/services.yaml +++ b/homeassistant/components/shell_command/services.yaml @@ -1 +1 @@ -# Empty file, shell_command services are dynamically created +reload: diff --git a/homeassistant/components/shell_command/strings.json b/homeassistant/components/shell_command/strings.json index f2f2dc1b819..a395ef9bd52 100644 --- a/homeassistant/components/shell_command/strings.json +++ b/homeassistant/components/shell_command/strings.json @@ -6,5 +6,17 @@ "timeout": { "message": "Timed out running command: `{command}`, after: {timeout} seconds" } + }, + "issues": { + "reserved_reload_name": { + "description": "The shell command name {name} is a reserved for the reload action and cannot be used for user-defined commands. Please rename or remove this entry from your configuration.", + "title": "Reserved shell command action name" + } + }, + "services": { + "reload": { + "description": "Reloads shell command configuration.", + "name": "[%key:common::action::reload%]" + } } } diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 2120f5e50e6..cc2a02ee87f 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 @@ -84,6 +82,7 @@ PLATFORMS: Final = [ Platform.COVER, Platform.EVENT, Platform.LIGHT, + Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, @@ -117,6 +116,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=hass-use-runtime-data hass.data[DOMAIN] = {CONF_COAP_PORT: conf[CONF_COAP_PORT]} async_setup_services(hass) diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py index 632e5277de5..31cff359fd0 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,28 @@ RPC_SENSORS: Final = { device_class=BinarySensorDeviceClass.OCCUPANCY, entity_class=RpcPresenceBinarySensor, ), + "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..87461fd4901 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 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..45db3c337df 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 @@ -807,7 +805,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 @@ -1086,7 +1084,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..4c6d32cd5e1 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, ) @@ -289,10 +287,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, diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 2a832c4dba4..1a47955f23b 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 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..27db1929597 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 diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 9b3e73bb4c1..13fa37359a1 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 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..1a3f1ef044b 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 diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 7400cc5cf06..4cd4082a2e5 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.25.0"], "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..3198053cd31 --- /dev/null +++ b/homeassistant/components/shelly/media_player.py @@ -0,0 +1,428 @@ +"""Media player for Shelly.""" + +import base64 +import binascii +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" + +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 + + 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 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.""" + if (thumb := self._media_meta.get("thumb")) and thumb.startswith("data"): + return hashlib.sha256(thumb.encode("utf-8")).hexdigest()[:16] + return super().media_image_hash + + 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["thumb"] + try: + prefix, image_data = thumb.split(",", 1) + image = base64.b64decode(image_data, validate=True) + mime = prefix.split(";", 1)[0].rsplit(":", 1)[-1] + except binascii.Error, ValueError: + return await super().async_get_media_image() + + return image, mime + + @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)}, + ) 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..73c3af9890c 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 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..e8beba55cc5 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,6 +40,7 @@ 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 .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator @@ -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, ), @@ -1242,9 +1240,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, diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index b61ce0af772..143c4827943 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": { @@ -341,7 +347,7 @@ "charger_end": "Charge completed", "charger_fault": "Error while charging", "charger_free": "[%key:component::binary_sensor::entity_component::plug::state::off%]", - "charger_free_fault": "Can not release plug", + "charger_free_fault": "Cannot release plug", "charger_insert": "[%key:component::binary_sensor::entity_component::plug::state::on%]", "charger_pause": "Charging paused by charger", "charger_wait": "Charging paused by vehicle" @@ -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}" }, @@ -795,7 +807,7 @@ }, "services": { "get_kvs_value": { - "description": "Get a value from the device's Key-Value Storage.", + "description": "Gets a value from a Shelly device's Key-Value Storage.", "fields": { "device_id": { "description": "The ID of the Shelly device to get the KVS value from.", @@ -806,10 +818,10 @@ "name": "Key" } }, - "name": "Get KVS value" + "name": "Get Shelly KVS value" }, "set_kvs_value": { - "description": "Set a value in the device's Key-Value Storage.", + "description": "Sets a value in a Shelly device's Key-Value Storage.", "fields": { "device_id": { "description": "The ID of the Shelly device to set the KVS value.", @@ -824,7 +836,7 @@ "name": "Value" } }, - "name": "Set KVS value" + "name": "Set Shelly KVS value" } } } diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 5a4f8debd1b..6ffb3dd7572 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 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..3268caf4776 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 diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 27afa335e5e..327c28b045a 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=hass-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..93a5626d779 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,26 +59,43 @@ 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: @@ -86,20 +103,19 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b 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) 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: @@ -107,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b 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,7 +141,7 @@ 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() hass.services.async_register( @@ -185,247 +201,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 +210,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 +222,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 +242,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 +255,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 +269,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 +283,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 +304,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 +337,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 +357,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 +377,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..96764dc30d4 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,13 +75,13 @@ 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}" ) diff --git a/homeassistant/components/shopping_list/strings.json b/homeassistant/components/shopping_list/strings.json index 7826f06618a..06ffc307b1a 100644 --- a/homeassistant/components/shopping_list/strings.json +++ b/homeassistant/components/shopping_list/strings.json @@ -26,15 +26,15 @@ "name": "[%key:common::config_flow::data::name%]" } }, - "name": "Add item" + "name": "Add shopping list item" }, "clear_completed_items": { "description": "Removes completed items from the shopping list.", - "name": "Clear completed items" + "name": "Clear completed shopping list items" }, "complete_all": { "description": "Marks all items as completed in the shopping list (without removing them from the list).", - "name": "Complete all" + "name": "Complete all shopping list items" }, "complete_item": { "description": "Marks the first item with matching name as completed in the shopping list.", @@ -44,11 +44,11 @@ "name": "[%key:common::config_flow::data::name%]" } }, - "name": "Complete item" + "name": "Complete shopping list item" }, "incomplete_all": { "description": "Marks all items as incomplete in the shopping list.", - "name": "Incomplete all" + "name": "Incomplete all shopping list items" }, "incomplete_item": { "description": "Marks the first item with matching name as incomplete in the shopping list.", @@ -58,7 +58,7 @@ "name": "[%key:common::config_flow::data::name%]" } }, - "name": "Incomplete item" + "name": "Incomplete shopping list item" }, "remove_item": { "description": "Removes the first item with matching name from the shopping list.", @@ -68,7 +68,7 @@ "name": "[%key:common::config_flow::data::name%]" } }, - "name": "Remove item" + "name": "Remove shopping list item" }, "sort": { "description": "Sorts all items by name in the shopping list.", @@ -78,7 +78,7 @@ "name": "Sort reverse" } }, - "name": "Sort all items" + "name": "Sort shopping list" } }, "title": "Shopping List" 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..136369f2087 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) @@ -179,7 +172,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 +184,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..88aff7728f1 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 diff --git a/homeassistant/components/sia/hub.py b/homeassistant/components/sia/hub.py index 591e4aadad7..c4d056314c5 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]) @@ -131,7 +131,7 @@ 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. @@ -139,8 +139,8 @@ class SIAHub: 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 != 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..5c54a4b3d2b 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 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..97ffd3d02fc 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 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/simplepush/config_flow.py b/homeassistant/components/simplepush/config_flow.py index 4e954e89938..5047466b9b0 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 diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index e21a62a6a12..fcd31e46f8e 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 diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index d9ab3e3b4f1..e9569d216bb 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 != 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) @@ -396,11 +400,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..d53ac20953c 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() ) diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py index b82162f0fe7..3ad1357cd53 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,8 +34,10 @@ 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 ) diff --git a/homeassistant/components/sinch/notify.py b/homeassistant/components/sinch/notify.py index 47f8d6b5a87..6954ca6a6e7 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 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 b33c2592255..e28698e5d41 100644 --- a/homeassistant/components/siren/strings.json +++ b/homeassistant/components/siren/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted sirens.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted sirens to trigger on.", - "trigger_behavior_name": "Behavior" + "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 sirens are off.", "fields": { "behavior": { - "description": "[%key:component::siren::common::condition_behavior_description%]", "name": "[%key:component::siren::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::siren::common::condition_for_name%]" } }, "name": "Siren is off" @@ -20,8 +22,10 @@ "description": "Tests if one or more sirens are on.", "fields": { "behavior": { - "description": "[%key:component::siren::common::condition_behavior_description%]", "name": "[%key:component::siren::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::siren::common::condition_for_name%]" } }, "name": "Siren is on" @@ -41,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.", @@ -90,8 +79,10 @@ "description": "Triggers after one or more sirens turn off.", "fields": { "behavior": { - "description": "[%key:component::siren::common::trigger_behavior_description%]", "name": "[%key:component::siren::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::siren::common::trigger_for_name%]" } }, "name": "Siren turned off" @@ -100,8 +91,10 @@ "description": "Triggers after one or more sirens turn on.", "fields": { "behavior": { - "description": "[%key:component::siren::common::trigger_behavior_description%]", "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..13fef66b2f0 100644 --- a/homeassistant/components/siren/triggers.yaml +++ b/homeassistant/components/siren/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any 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/__init__.py b/homeassistant/components/sky_hub/__init__.py index a5b8969018f..3c465305acd 100644 --- a/homeassistant/components/sky_hub/__init__.py +++ b/homeassistant/components/sky_hub/__init__.py @@ -1 +1 @@ -"""The sky_hub component.""" +"""The Sky Hub integration.""" 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/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index 108539c1cef..9d460c81510 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 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..444686b50be 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 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/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..b36637a9812 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 @@ -23,6 +21,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, IS_IN_BED, SLEEP_NUMBER from .coordinator import ( + SleepIQConfigEntry, SleepIQData, SleepIQDataUpdateCoordinator, SleepIQPauseUpdateCoordinator, @@ -64,7 +63,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: SleepIQConfigEntry) -> bool: """Set up the SleepIQ config entry.""" conf = entry.data email = conf[CONF_USERNAME] @@ -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/coordinator.py b/homeassistant/components/sleepiq/coordinator.py index 0baeca03fe5..d15094049cf 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.""" @@ -51,12 +53,12 @@ class SleepIQDataUpdateCoordinator(DataUpdateCoordinator[None]): class SleepIQPauseUpdateCoordinator(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.""" @@ -78,12 +80,12 @@ class SleepIQPauseUpdateCoordinator(DataUpdateCoordinator[None]): 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..cd2afcbb193 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 @@ -180,11 +176,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..d510e492611 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( diff --git a/homeassistant/components/sleepiq/sensor.py b/homeassistant/components/sleepiq/sensor.py index 5d22897d97b..0b889ccc882 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,13 +11,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, HEART_RATE, HRV, PRESSURE, @@ -29,7 +25,7 @@ from .const import ( SLEEP_SCORE, ) from .coordinator import ( - SleepIQData, + SleepIQConfigEntry, SleepIQDataUpdateCoordinator, SleepIQSleepDataCoordinator, ) @@ -112,11 +108,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/coordinator.py b/homeassistant/components/slide_local/coordinator.py index e4c8179d494..5c465da70a5 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 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/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..abb64a52cf4 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 diff --git a/homeassistant/components/sma/coordinator.py b/homeassistant/components/sma/coordinator.py index 5fd00ad9f50..1d4c3762e0b 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 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/smappee/__init__.py b/homeassistant/components/smappee/__init__.py index 372441ec586..55b507e51c1 100644 --- a/homeassistant/components/smappee/__init__.py +++ b/homeassistant/components/smappee/__init__.py @@ -1,4 +1,5 @@ """The Smappee integration.""" +# pylint: disable=hass-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..6e06845c497 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=hass-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/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/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..80318d85d20 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -9,32 +9,24 @@ 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 diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 47fc16bf879..bba87d3afa8 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 diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 947abde50f7..825473b3006 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 diff --git a/homeassistant/components/smartthings/button.py b/homeassistant/components/smartthings/button.py index 61aaeab13f6..d743c46fc16 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 diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 526c5840881..2d7f8c00387 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 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..5afffbdf284 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 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 8ec347a5edf..5cc4530e97a 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.2"] + "requirements": ["pysmartthings==3.7.3"] } diff --git a/homeassistant/components/smartthings/media_player.py b/homeassistant/components/smartthings/media_player.py index 335e8255ae4..07414452893 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 diff --git a/homeassistant/components/smartthings/number.py b/homeassistant/components/smartthings/number.py index 1f4779eab81..0943891c1fa 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 diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py index b91cd641080..3fd9a006aab 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 diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 1f303013182..0595cf20093 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 @@ -1286,6 +1284,8 @@ CAPABILITY_TO_SENSORS: dict[ UNITS = { "C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT, + "Celsius": UnitOfTemperature.CELSIUS, + "Fahrenheit": UnitOfTemperature.FAHRENHEIT, "ccf": UnitOfVolume.CENTUM_CUBIC_FEET, "lux": LIGHT_LUX, "mG": None, diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 6deaefceae4..78a34e0339e 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -1014,6 +1014,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..67a3ef2b684 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,6 +78,13 @@ SWITCH = SmartThingsSwitchEntityDescription( CAPABILITY_TO_COMMAND_SWITCHES: dict[ Capability | str, SmartThingsCommandSwitchEntityDescription ] = { + 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", diff --git a/homeassistant/components/smartthings/time.py b/homeassistant/components/smartthings/time.py index de4057d4ac1..d87fd4bdeae 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 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..55d9c2802a3 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 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/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/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/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/manifest.json b/homeassistant/components/smhi/manifest.json index dbaf57364d6..a2ab45a839f 100644 --- a/homeassistant/components/smhi/manifest.json +++ b/homeassistant/components/smhi/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pysmhi"], - "requirements": ["pysmhi==1.1.0"] + "requirements": ["pysmhi==2.0.0"] } diff --git a/homeassistant/components/smhi/sensor.py b/homeassistant/components/smhi/sensor.py index 7531e4e4d6d..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 @@ -56,6 +54,21 @@ FORESTDRY_MAP = { "5": "very_dry", "6": "extremely_dry", } +PRECIPITATION_CATEGORY_MAP = { + 0: "no_precipitation", + 1: "rain", + 2: "thunderstorm", + 3: "freezing_rain", + 4: "mixed_ice", + 5: "snow", + 6: "wet_snow", + 7: "rain_snow_mixed", + 8: "ice_pellets", + 9: "graupel", + 10: "hail", + 11: "drizzle", + 12: "freezing_drizzle", +} def get_percentage_values(entity: SMHIWeatherSensor, key: str) -> int | None: @@ -68,6 +81,14 @@ def get_percentage_values(entity: SMHIWeatherSensor, key: str) -> int | None: return None +def get_precipitation_category(entity: SMHIWeatherSensor) -> str | None: + """Return the precipitation category.""" + value: int | None = entity.coordinator.current.get("precipitation_category") + if value in PRECIPITATION_CATEGORY_MAP: + return PRECIPITATION_CATEGORY_MAP[value] + return None + + def get_fire_index_value(entity: SMHIFireSensor, key: str) -> str: """Return index value as string.""" value: int | None = entity.coordinator.fire_current.get(key) # type: ignore[assignment] @@ -128,11 +149,9 @@ WEATHER_SENSOR_DESCRIPTIONS: tuple[SMHIWeatherEntityDescription, ...] = ( SMHIWeatherEntityDescription( key="precipitation_category", translation_key="precipitation_category", - value_fn=lambda entity: str( - get_percentage_values(entity, "precipitation_category") - ), + value_fn=get_precipitation_category, device_class=SensorDeviceClass.ENUM, - options=["0", "1", "2", "3", "4", "5", "6"], + options=[*PRECIPITATION_CATEGORY_MAP.values()], ), SMHIWeatherEntityDescription( key="frozen_precipitation", diff --git a/homeassistant/components/smhi/strings.json b/homeassistant/components/smhi/strings.json index 50fb0c4c2c9..fc940ca3e5f 100644 --- a/homeassistant/components/smhi/strings.json +++ b/homeassistant/components/smhi/strings.json @@ -95,13 +95,19 @@ "precipitation_category": { "name": "Precipitation category", "state": { - "0": "No precipitation", - "1": "Snow", - "2": "Snow and rain", - "3": "Rain", - "4": "Drizzle", - "5": "Freezing rain", - "6": "Freezing drizzle" + "drizzle": "Drizzle", + "freezing_drizzle": "Freezing drizzle", + "freezing_rain": "Freezing rain", + "graupel": "Graupel", + "hail": "Hail", + "ice_pellets": "Ice pellets", + "mixed_ice": "Mixed/ice", + "no_precipitation": "No precipitation", + "rain": "Rain", + "rain_snow_mixed": "Mixture of rain and snow", + "snow": "Snow", + "thunderstorm": "Thunderstorm", + "wet_snow": "Wet snow" } }, "rate_of_spread": { 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 b815b4d74a2..71b0e7b65d3 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -1,7 +1,5 @@ """SMLIGHT SLZB Zigbee device integration.""" -from __future__ import annotations - from pysmlight import Api2 from homeassistant.const import CONF_HOST, Platform @@ -18,8 +16,8 @@ from .coordinator import ( PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.INFRARED, Platform.LIGHT, - Platform.REMOTE, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, 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..f88f1d81a25 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, diff --git a/homeassistant/components/smlight/infrared.py b/homeassistant/components/smlight/infrared.py new file mode 100644 index 00000000000..063d4f3bab9 --- /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, InfraredEntity +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, InfraredEntity): + """Representation of a SLZB-Ultima infrared.""" + + _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/manifest.json b/homeassistant/components/smlight/manifest.json index 985799ab0e6..e727bf20a34 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -12,7 +12,7 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["pysmlight==0.3.1"], + "requirements": ["pysmlight==0.3.2"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/homeassistant/components/smlight/remote.py b/homeassistant/components/smlight/remote.py deleted file mode 100644 index 4976c7688f2..00000000000 --- a/homeassistant/components/smlight/remote.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Remote platform for SLZB-Ultima.""" - -import asyncio -from collections.abc import Iterable -from typing import Any - -from pysmlight.exceptions import SmlightError -from pysmlight.models import IRPayload - -from homeassistant.components.remote import ( - ATTR_DELAY_SECS, - ATTR_NUM_REPEATS, - DEFAULT_DELAY_SECS, - DEFAULT_NUM_REPEATS, - RemoteEntity, -) -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 remote for SLZB-Ultima device.""" - coordinator = entry.runtime_data.data - - if coordinator.data.info.has_peripherals: - async_add_entities([SmRemoteEntity(coordinator)]) - - -class SmRemoteEntity(SmEntity, RemoteEntity): - """Representation of a SLZB-Ultima remote.""" - - _attr_translation_key = "remote" - _attr_is_on = True - - def __init__(self, coordinator: SmDataUpdateCoordinator) -> None: - """Initialize the SLZB-Ultima remote.""" - super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.unique_id}-remote" - - async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: - """Send a sequence of commands to a device.""" - num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS) - delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) - - for _ in range(num_repeats): - for cmd in command: - try: - await self.coordinator.async_execute_command( - self.coordinator.client.actions.send_ir_code, - IRPayload(code=cmd), - ) - except SmlightError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="send_ir_code_failed", - translation_placeholders={"error": str(err)}, - ) from err - - await asyncio.sleep(delay_secs) 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/strings.json b/homeassistant/components/smlight/strings.json index 10310d4c6ef..31fb16650a9 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -79,16 +79,16 @@ "name": "Zigbee restart" } }, + "infrared": { + "infrared_emitter": { + "name": "IR emitter" + } + }, "light": { "ambilight": { "name": "Ambilight" } }, - "remote": { - "remote": { - "name": "IR Remote" - } - }, "sensor": { "core_temperature": { "name": "Core chip temp" 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..fe6d5980b3a 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 diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 96b1a7e8e9f..532816f8f93 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 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..3f7e12b44cc 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() 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/__init__.py b/homeassistant/components/snmp/__init__.py index 4a049ee1553..1da23965dce 100644 --- a/homeassistant/components/snmp/__init__.py +++ b/homeassistant/components/snmp/__init__.py @@ -1,4 +1,4 @@ -"""The snmp component.""" +"""The SNMP integration.""" from .util import async_get_snmp_engine diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 1f94a1c4fae..5dc6728f0af 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 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..5fb2c3529ba 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 diff --git a/homeassistant/components/snooz/__init__.py b/homeassistant/components/snooz/__init__.py index c97c89c2f4a..1291825efaa 100644 --- a/homeassistant/components/snooz/__init__.py +++ b/homeassistant/components/snooz/__init__.py @@ -1,22 +1,19 @@ """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.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 .const import PLATFORMS +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] @@ -31,33 +28,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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..85de4758322 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 diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index ce804450cab..3d56cdb32d6 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): 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/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..d8553aad067 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 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..6d2b98837da 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -1,7 +1,5 @@ """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 @@ -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: @@ -334,6 +333,86 @@ class SolarEdgePowerFlowDataService(SolarEdgeDataService): 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.""" diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index b56c35be160..bc93f45b308 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 @@ -207,6 +206,64 @@ SENSOR_TYPES = [ 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 +279,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 +336,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, @@ -289,6 +383,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 +460,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.""" @@ -434,3 +572,41 @@ class SolarEdgeStorageLevelSensor(SolarEdgeSensorEntity): if attr and "soc" in attr: return attr["soc"] return None + + +class SolarEdgeStorageDataSensor(SolarEdgeSensorEntity): + """Representation of an SolarEdge aggregate storage data sensor.""" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + 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..0225262e973 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" }, @@ -139,6 +151,12 @@ "solar_power": { "name": "Solar power" }, + "storage_charge_energy": { + "name": "Storage charge energy today" + }, + "storage_discharge_energy": { + "name": "Storage discharge energy today" + }, "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/const.py b/homeassistant/components/solarlog/const.py index 3e814705589..f757a21cda2 100644 --- a/homeassistant/components/solarlog/const.py +++ b/homeassistant/components/solarlog/const.py @@ -1,7 +1,5 @@ """Constants for the Solar-Log integration.""" -from __future__ import annotations - DOMAIN = "solarlog" # Default config for solarlog. diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py index cc3028a3e7c..6bb299d92ac 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 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..9ad99c88810 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 diff --git a/homeassistant/components/solarlog/manifest.json b/homeassistant/components/solarlog/manifest.json index b9b47dbbaa2..9b7d7eb183f 100644 --- a/homeassistant/components/solarlog/manifest.json +++ b/homeassistant/components/solarlog/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["solarlog_cli"], "quality_scale": "platinum", - "requirements": ["solarlog_cli==0.7.0"] + "requirements": ["solarlog_cli==0.7.1"] } 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..d6785bf5915 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 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..be85f85f00b 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,17 @@ 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"]} + 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..02aad0bc203 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,28 +9,27 @@ 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: + 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)) 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..83948a6137c 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 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 522009785b1..e0943139ef4 100644 --- a/homeassistant/components/sonarr/helpers.py +++ b/homeassistant/components/sonarr/helpers.py @@ -276,7 +276,7 @@ def format_upcoming( for episode in calendar: # Create a unique key combining series title and episode identifier - series_title = episode.series.title if hasattr(episode, "series") else "Unknown" + series_title = episode.series.title if hasattr(episode, "series") else "Unknown" # type: ignore[misc] identifier = f"S{episode.seasonNumber:02d}E{episode.episodeNumber:02d}" key = f"{series_title} {identifier}" episodes[key] = format_upcoming_item(episode, base_url) @@ -324,7 +324,7 @@ def format_wanted( for item in wanted.records: # Create a unique key combining series title and episode identifier series_title = ( - item.series.title if hasattr(item, "series") and item.series else "Unknown" + item.series.title if hasattr(item, "series") and item.series else "Unknown" # type: ignore[misc] ) identifier = f"S{item.seasonNumber:02d}E{item.episodeNumber:02d}" key = f"{series_title} {identifier}" diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py index 3aeb4348e6d..02139d743f6 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 @@ -65,9 +63,9 @@ def get_queue_attr(queue: SonarrQueue) -> dict[str, str]: remaining = 1 if item.size == 0 else item.sizeleft / item.size remaining_pct = 100 * (1 - remaining) identifier = ( - f"S{item.episode.seasonNumber:02d}E{item.episode.episodeNumber:02d}" + f"S{item.episode.seasonNumber:02d}E{item.episode.episodeNumber:02d}" # type: ignore[misc] ) - attrs[f"{item.series.title} {identifier}"] = f"{remaining_pct:.2f}%" + attrs[f"{item.series.title} {identifier}"] = f"{remaining_pct:.2f}%" # type: ignore[misc] return attrs @@ -77,7 +75,7 @@ def get_wanted_attr(wanted: SonarrWantedMissing) -> dict[str, str]: for item in wanted.records: identifier = f"S{item.seasonNumber:02d}E{item.episodeNumber:02d}" - name = f"{item.series.title} {identifier}" + name = f"{item.series.title} {identifier}" # type: ignore[misc] attrs[name] = dt_util.as_local( item.airDateUtc.replace(tzinfo=dt_util.UTC) ).isoformat() @@ -126,7 +124,8 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = { translation_key="upcoming", value_fn=len, attributes_fn=lambda data: { - e.series.title: f"S{e.seasonNumber:02d}E{e.episodeNumber:02d}" for e in data + e.series.title: f"S{e.seasonNumber:02d}E{e.episodeNumber:02d}" # type: ignore[misc] + for e in data }, ), "wanted": SonarrSensorEntityDescription[SonarrWantedMissing]( 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..e90e66cabd9 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 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 33d82e07288..a53f3d14bdf 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=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio import datetime @@ -417,6 +416,7 @@ class SonosDiscoveryManager: ) new_coordinator.setup(soco) c_dict[soco.household_id] = new_coordinator + c_dict[soco.household_id].add_speaker(soco) speaker.setup(self.entry) except (OSError, SoCoException, Timeout) as ex: _LOGGER.warning("Failed to add SonosSpeaker using %s: %s", soco, ex) diff --git a/homeassistant/components/sonos/alarms.py b/homeassistant/components/sonos/alarms.py index c3c3b14545f..9cdec3b9689 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,40 @@ 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, @@ -99,3 +117,7 @@ class SonosAlarms(SonosHouseholdCoordinator): ) self.last_processed_event_id = self.alarms.last_id return True + + def add_speaker(self, soco: SoCo) -> None: + """Update any skipped alarms when speaker is added.""" + self.alarms.update_skipped(soco) 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..7ffbf475e2b 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 @@ -115,6 +113,9 @@ class SonosPollingEntity(SonosEntity): 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..801fd0bc99c 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 diff --git a/homeassistant/components/sonos/household_coordinator.py b/homeassistant/components/sonos/household_coordinator.py index a2c128dce94..636f3e27d43 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 @@ -85,3 +83,6 @@ class SonosHouseholdCoordinator: 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.""" raise NotImplementedError + + def add_speaker(self, soco: SoCo) -> None: + """Additional processing when a speaker is added if needed.""" diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index beac2ffc343..d9730170c81 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.14", + "soco==0.30.15", "sonos-websocket==0.1.3" ], "ssdp": [ diff --git a/homeassistant/components/sonos/media.py b/homeassistant/components/sonos/media.py index 6e8c629560b..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 @@ -132,7 +130,8 @@ class SonosMedia: self.artist = track_info.get("artist") self.album_name = track_info.get("album") - self.title = track_info.get("title") + title = track_info.get("title") or "" + self.title = title.strip() or None self.image_url = track_info.get("album_art") playlist_position = int(track_info.get("playlist_position", -1)) diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 768aaf529a1..25773973856 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 diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index f7a4420704b..f777eb00cb9 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -1,7 +1,5 @@ """Support to interface with Sonos players.""" -from __future__ import annotations - import datetime from functools import partial import logging 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..dd8b1ac4a40 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 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..79232d424ac 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 @@ -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) 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..386dcfb452f 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" } } }, @@ -129,6 +135,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 +182,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 +202,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..6e5a65b735f 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 needed for entity creation in a single executor call.""" + 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,135 @@ 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..a2eea46fa03 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(): + 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/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/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..f042555854f 100644 --- a/homeassistant/components/splunk/config_flow.py +++ b/homeassistant/components/splunk/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Splunk integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any 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/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..d82b9a7442a 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 diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 44ee32ec8e8..01a2448526c 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.49", "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..4497b26cff5 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 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/util.py b/homeassistant/components/sql/util.py index 7433462f125..ad2bba61dcc 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=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from datetime import date from decimal import Decimal diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 3ba320091a6..4383be1eb6d 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) @@ -216,6 +221,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 +267,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..f89411687e8 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 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..86dc6a9e816 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 diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index c078fc377b5..4f36db6644e 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 @@ -138,3 +136,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/media_player.py b/homeassistant/components/squeezebox/media_player.py index 094f50397a6..bb322961ce6 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 @@ -292,10 +290,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).""" diff --git a/homeassistant/components/squeezebox/quality_scale.yaml b/homeassistant/components/squeezebox/quality_scale.yaml index 0817aead782..2df336aeca7 100644 --- a/homeassistant/components/squeezebox/quality_scale.yaml +++ b/homeassistant/components/squeezebox/quality_scale.yaml @@ -22,8 +22,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: done + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done 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/update.py b/homeassistant/components/squeezebox/update.py index db235786817..6b7af58209a 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 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..c2b91b6bfd5 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 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/manifest.json b/homeassistant/components/srp_energy/manifest.json index 27deb87b0ca..ccbe73a97fd 100644 --- a/homeassistant/components/srp_energy/manifest.json +++ b/homeassistant/components/srp_energy/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["srpenergy"], - "requirements": ["srpenergy==1.3.6"] + "requirements": ["srpenergy==1.3.8"] } 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..9ba47efcedb 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 diff --git a/homeassistant/components/ssdp/server.py b/homeassistant/components/ssdp/server.py index 01756d3f06b..b209072312f 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 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..e5bc38b4efe 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(): @@ -92,20 +90,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..860d603673d 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 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..472d8f72643 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, ...] = ( @@ -91,11 +88,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..ee3d73cc83f 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 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/manifest.json b/homeassistant/components/starlink/manifest.json index c66896e0d4e..fcc397238dc 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/starlink", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["starlink-grpc-core==1.2.4"] + "requirements": ["starlink-grpc-core==1.2.5"] } diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index 81913a997ea..efc820a9d49 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 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..e9d231bc64a 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 diff --git a/homeassistant/components/startca/__init__.py b/homeassistant/components/startca/__init__.py index aca4a424a36..fe2ab1fd151 100644 --- a/homeassistant/components/startca/__init__.py +++ b/homeassistant/components/startca/__init__.py @@ -1 +1 @@ -"""The startca component.""" +"""The Start.ca integration.""" 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..74d6bf7d2a9 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 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..a2718178e82 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=hass-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..b4c175f0a65 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=hass-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..789bd506bcb 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=hass-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..b2ceb53ab97 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 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..fab20d442bc 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 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..f75d00e3d99 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 diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 6d2ca7865f9..b47763a3ab2 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==16.0.1", "numpy==2.3.2"] } diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index df80774d595..2c991bd6064 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 @@ -169,11 +167,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 +177,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 +202,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..5ec94f9b153 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, @@ -223,7 +239,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 +250,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: diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py index 1c1357a9b2b..ccbbcf53a50 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: @@ -55,9 +54,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..0ff9d6bec2c 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,12 +32,15 @@ API_GEN_3 = "g3" MANUFACTURER = "Subaru" PLATFORMS = [ + Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.LOCK, Platform.SENSOR, ] SERVICE_LOCK = "lock" +SERVICE_REMOTE_START = "remote_start" +SERVICE_REMOTE_STOP = "remote_stop" SERVICE_UNLOCK = "unlock" SERVICE_UNLOCK_SPECIFIC_DOOR = "unlock_specific_door" diff --git a/homeassistant/components/subaru/coordinator.py b/homeassistant/components/subaru/coordinator.py index 73aec22250a..638ba25f68f 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]], 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..8af699fb45f 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() diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 930f497d3fe..a7f384d602f 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.15"] + "requirements": ["subarulink==0.7.19"] } diff --git a/homeassistant/components/subaru/remote_service.py b/homeassistant/components/subaru/remote_service.py index acd71e186da..1a20ad04d72 100644 --- a/homeassistant/components/subaru/remote_service.py +++ b/homeassistant/components/subaru/remote_service.py @@ -6,7 +6,7 @@ from subarulink.exceptions import SubaruException from homeassistant.exceptions import HomeAssistantError -from .const import SERVICE_UNLOCK, VEHICLE_NAME, VEHICLE_VIN +from .const import SERVICE_REMOTE_START, SERVICE_UNLOCK, VEHICLE_NAME, VEHICLE_VIN _LOGGER = logging.getLogger(__name__) @@ -20,7 +20,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 dfb49e414b6..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 @@ -84,7 +82,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) - await gateway.connect() except DaliGatewayError as exc: raise ConfigEntryNotReady( - "You can try to delete the gateway and add it again" + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": entry.data[CONF_HOST]}, ) from exc try: @@ -94,7 +94,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) - ) except DaliGatewayError as exc: raise ConfigEntryNotReady( - "Unable to discover devices from the gateway" + translation_domain=DOMAIN, + translation_key="cannot_discover_devices", ) from exc _LOGGER.debug("Discovered %d devices on gateway %s", len(devices), gw_sn) diff --git a/homeassistant/components/sunricher_dali/binary_sensor.py b/homeassistant/components/sunricher_dali/binary_sensor.py index a2a5646d2d0..cbd26ec39fb 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 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..4332a0e2644 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.20.4"] } diff --git a/homeassistant/components/sunricher_dali/quality_scale.yaml b/homeassistant/components/sunricher_dali/quality_scale.yaml index 27b40e9335d..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 @@ -61,10 +61,17 @@ rules: dynamic-devices: todo entity-category: done entity-device-class: done - entity-disabled-by-default: todo + entity-disabled-by-default: + status: exempt + comment: No noisy or non-essential entities to disable. entity-translations: done - exception-translations: todo - icon-translations: todo + exception-translations: done + icon-translations: + status: exempt + comment: | + No entities define custom icons (no icon/_attr_icon); icons are provided + by the entity platforms via their defaults and device classes where + applicable. reconfiguration-flow: todo repair-issues: todo stale-devices: todo 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/sunricher_dali/strings.json b/homeassistant/components/sunricher_dali/strings.json index 5a2eccf42b2..64fbe7ad1aa 100644 --- a/homeassistant/components/sunricher_dali/strings.json +++ b/homeassistant/components/sunricher_dali/strings.json @@ -23,5 +23,13 @@ "description": "**Three-step process:**\n\n1. Ensure the gateway is powered and on the same network.\n2. Select **Submit** to start discovery (searches for up to 3 minutes)\n3. While discovery is in progress, press the **Reset** button on your Sunricher DALI gateway device **once**.\n\nThe gateway will respond immediately after the button press." } } + }, + "exceptions": { + "cannot_connect": { + "message": "Could not connect to the gateway at {host}. Please check that the device is powered on and reachable" + }, + "cannot_discover_devices": { + "message": "Unable to discover devices and scenes from the gateway." + } } } 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..46a9b1604c2 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,7 +7,6 @@ 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.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -24,7 +21,7 @@ from .const import ( SERVICE_SET_LOCK_STATE, SERVICE_SET_PET_LOCATION, ) -from .coordinator import SurePetcareDataCoordinator +from .coordinator import SurePetcareConfigEntry, SurePetcareDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -32,15 +29,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 +41,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( @@ -91,10 +84,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/coordinator.py b/homeassistant/components/surepetcare/coordinator.py index d8112cebc90..746e4c27bbe 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 @@ -29,13 +27,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/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/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 842dc657817..902449c9f86 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -1,7 +1,5 @@ """Support for Swisscom routers (Internet-Box).""" -from __future__ import annotations - from contextlib import suppress import logging 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 93b406e4eb1..40576351e13 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted switches.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted switches to trigger on.", - "trigger_behavior_name": "Behavior" + "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 switches are off.", "fields": { "behavior": { - "description": "[%key:component::switch::common::condition_behavior_description%]", "name": "[%key:component::switch::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::switch::common::condition_for_name%]" } }, "name": "Switch is off" @@ -20,8 +22,10 @@ "description": "Tests if one or more switches are on.", "fields": { "behavior": { - "description": "[%key:component::switch::common::condition_behavior_description%]", "name": "[%key:component::switch::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::switch::common::condition_for_name%]" } }, "name": "Switch is on" @@ -69,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.", @@ -104,8 +93,10 @@ "description": "Triggers after one or more switches turn off.", "fields": { "behavior": { - "description": "[%key:component::switch::common::trigger_behavior_description%]", "name": "[%key:component::switch::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::switch::common::trigger_for_name%]" } }, "name": "Switch turned off" @@ -114,8 +105,10 @@ "description": "Triggers after one or more switches turn on.", "fields": { "behavior": { - "description": "[%key:component::switch::common::trigger_behavior_description%]", "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..d501286b000 100644 --- a/homeassistant/components/switch/triggers.yaml +++ b/homeassistant/components/switch/triggers.yaml @@ -8,12 +8,13 @@ required: true default: any 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..1e831306e87 100644 --- a/homeassistant/components/switchbee/button.py +++ b/homeassistant/components/switchbee/button.py @@ -4,23 +4,21 @@ 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() 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/light.py b/homeassistant/components/switchbee/light.py index 228667540df..b5d893dc3e0 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,13 +32,13 @@ 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 ) diff --git a/homeassistant/components/switchbee/switch.py b/homeassistant/components/switchbee/switch.py index 41538f6fd71..f7e41c31710 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) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index a2768c202b7..43f36dc88e8 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -1,7 +1,5 @@ """Support for Switchbot devices.""" -from __future__ import annotations - import logging from typing import Any @@ -58,6 +56,7 @@ PLATFORMS_BY_TYPE = { SupportedModels.HYGROMETER.value: [Platform.SENSOR], SupportedModels.HYGROMETER_CO2.value: [ Platform.BUTTON, + Platform.NUMBER, Platform.SENSOR, Platform.SELECT, ], @@ -110,11 +109,34 @@ PLATFORMS_BY_TYPE = { 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.EVAPORATIVE_HUMIDIFIER: [Platform.HUMIDIFIER, Platform.SENSOR], + SupportedModels.AIR_PURIFIER_JP.value: [ + Platform.FAN, + Platform.SENSOR, + Platform.BUTTON, + Platform.SWITCH, + ], + SupportedModels.AIR_PURIFIER_US.value: [ + Platform.FAN, + Platform.SENSOR, + Platform.BUTTON, + Platform.SWITCH, + ], + SupportedModels.AIR_PURIFIER_TABLE_JP.value: [ + Platform.FAN, + Platform.SENSOR, + Platform.BUTTON, + Platform.SWITCH, + ], + SupportedModels.AIR_PURIFIER_TABLE_US.value: [ + Platform.FAN, + Platform.SENSOR, + Platform.BUTTON, + Platform.SWITCH, + ], + SupportedModels.EVAPORATIVE_HUMIDIFIER.value: [ + Platform.HUMIDIFIER, + Platform.SENSOR, + ], SupportedModels.FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.STRIP_LIGHT_3.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.RGBICWW_FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], @@ -171,7 +193,7 @@ 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: 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, diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index ef035bbfdf2..b91102903c7 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -1,7 +1,5 @@ """Support for SwitchBot binary sensors.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass diff --git a/homeassistant/components/switchbot/button.py b/homeassistant/components/switchbot/button.py index 3d9db9074f2..f68a45390cb 100644 --- a/homeassistant/components/switchbot/button.py +++ b/homeassistant/components/switchbot/button.py @@ -24,6 +24,8 @@ async def async_setup_entry( ) -> None: """Set up Switchbot button platform.""" coordinator = entry.runtime_data + if isinstance(coordinator.device, switchbot.SwitchbotAirPurifier): + async_add_entities([LightSensorButton(coordinator)]) if isinstance(coordinator.device, switchbot.SwitchbotArtFrame): async_add_entities( @@ -37,6 +39,24 @@ async def async_setup_entry( async_add_entities([SwitchBotMeterProCO2SyncDateTimeButton(coordinator)]) +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): """Base class for Art Frame buttons.""" 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..eecdf6b3257 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 @@ -96,6 +94,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 +196,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 +264,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 +279,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] diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index d871f18d964..142b13befcc 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -206,3 +206,16 @@ CONF_KEY_ID = "key_id" CONF_ENCRYPTION_KEY = "encryption_key" CONF_LOCK_NIGHTLATCH = "lock_force_nightlatch" CONF_CURTAIN_SPEED = "curtain_speed" + +AIRPURIFIER_BASIC_MODELS = { + SwitchbotModel.AIR_PURIFIER_JP, + SwitchbotModel.AIR_PURIFIER_US, +} +AIRPURIFIER_TABLE_MODELS = { + SwitchbotModel.AIR_PURIFIER_TABLE_JP, + SwitchbotModel.AIR_PURIFIER_TABLE_US, +} +AIRPURIFIER_PM25_MODELS = { + SwitchbotModel.AIR_PURIFIER_US, + SwitchbotModel.AIR_PURIFIER_TABLE_US, +} 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..61750efbb4a 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 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..83279a25820 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 diff --git a/homeassistant/components/switchbot/event.py b/homeassistant/components/switchbot/event.py index 30ccca7ea95..e5a4e0598fa 100644 --- a/homeassistant/components/switchbot/event.py +++ b/homeassistant/components/switchbot/event.py @@ -1,7 +1,5 @@ """Support for SwitchBot event entities.""" -from __future__ import annotations - from homeassistant.components.event import ( EventDeviceClass, EventEntity, @@ -52,12 +50,14 @@ class SwitchbotEventEntity(SwitchbotEntity, EventEntity): self._event = event self.entity_description = description self._attr_unique_id = f"{coordinator.base_unique_id}-{event}" - self._previous_value = False + self._previous_doorbell_seq = int( + coordinator.device.parsed_data.get("doorbell_seq", 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: + seq = int(self.parsed_data.get("doorbell_seq", 0)) + if seq not in (0, self._previous_doorbell_seq): self._trigger_event("ring") - self._previous_value = value + self._previous_doorbell_seq = seq diff --git a/homeassistant/components/switchbot/fan.py b/homeassistant/components/switchbot/fan.py index 9a7260f5925..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 @@ -131,6 +129,7 @@ class SwitchBotAirPurifierEntity(SwitchbotEntity, FanEntity): _device: switchbot.SwitchbotAirPurifier _attr_supported_features = ( FanEntityFeature.PRESET_MODE + | FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON ) @@ -148,6 +147,11 @@ class SwitchBotAirPurifierEntity(SwitchbotEntity, FanEntity): """Return the current preset mode.""" return self._device.get_current_mode() + @property + def percentage(self) -> int | None: + """Return the speed percentage of the air purifier.""" + return self._device.get_current_percentage() + @exception_handler async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the air purifier.""" @@ -160,6 +164,16 @@ class SwitchBotAirPurifierEntity(SwitchbotEntity, FanEntity): self._last_run_success = bool(await self._device.set_preset_mode(preset_mode)) self.async_write_ha_state() + @exception_handler + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the air purifier.""" + + _LOGGER.debug( + "Switchbot air purifier to set percentage %d %s", percentage, self._address + ) + await self._device.set_percentage(percentage) + self.async_write_ha_state() + @exception_handler async def async_turn_on( self, 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..7321ac67120 100644 --- a/homeassistant/components/switchbot/icons.json +++ b/homeassistant/components/switchbot/icons.json @@ -1,5 +1,10 @@ { "entity": { + "button": { + "light_sensor": { + "default": "mdi:brightness-auto" + } + }, "climate": { "climate": { "state_attributes": { @@ -116,6 +121,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 +163,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..a6832ad36c5 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 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/quality_scale.yaml b/homeassistant/components/switchbot/quality_scale.yaml index 5226016c527..21b79d086b3 100644 --- a/homeassistant/components/switchbot/quality_scale.yaml +++ b/homeassistant/components/switchbot/quality_scale.yaml @@ -39,9 +39,7 @@ rules: comment: | Once a cryptographic key is successfully obtained for SwitchBot devices, it will be granted perpetual validity with no expiration constraints. - test-coverage: - status: done - + test-coverage: done # Gold devices: done diagnostics: done 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..49f9d4ca4a8 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -102,6 +102,9 @@ } }, "button": { + "light_sensor": { + "name": "Light sensor" + }, "next_image": { "name": "Next image" }, @@ -269,6 +272,11 @@ } } }, + "number": { + "display_time_offset": { + "name": "Display time offset" + } + }, "select": { "time_format": { "name": "Time format", @@ -288,6 +296,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 +340,12 @@ } } } + }, + "child_lock": { + "name": "Child lock" + }, + "wireless_charging": { + "name": "Wireless charging" } }, "vacuum": { 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..8ab53db23bd 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 @@ -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, @@ -202,7 +206,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: @@ -329,7 +333,9 @@ async def make_device_data( devices_data.sensors.append((device, coordinator)) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +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 +358,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 +367,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 +411,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( diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index dac916c6cae..494d64a9320 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 @@ -137,11 +135,11 @@ 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) diff --git a/homeassistant/components/switchbot_cloud/button.py b/homeassistant/components/switchbot_cloud/button.py index d64139a052c..3e493ab9036 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,11 +56,11 @@ 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: description_set = BUTTON_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type] 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..809a289a353 100644 --- a/homeassistant/components/switchbot_cloud/const.py +++ b/homeassistant/components/switchbot_cloud/const.py @@ -61,3 +61,25 @@ 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] 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/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..2b3d7c3e8f2 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,), @@ -267,11 +286,11 @@ 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]: @@ -315,7 +334,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..2bd4ff41bcc 100644 --- a/homeassistant/components/switchbot_cloud/strings.json +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -76,6 +76,18 @@ "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" + } } } } 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..7a0fd7471e0 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 diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index ba4bc4dc776..bb7e5d40fe0 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 diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 8ed64d5f039..e9f3447d234 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 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..ac309ba29c0 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 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..8157f92a344 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 diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py index d253c340788..ba509d62302 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 diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index d79b319fc6e..35fd1db0751 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 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/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..23a370582eb 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 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..cec61912b82 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -1,7 +1,7 @@ { "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", 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..7d8532c0af3 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 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..6ff70ae85ba 100644 --- a/homeassistant/components/synology_dsm/services.py +++ b/homeassistant/components/synology_dsm/services.py @@ -1,7 +1,5 @@ """The Synology DSM component.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING, cast diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json index f31e7934420..aedd23f5772 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%]" }, @@ -78,7 +78,7 @@ }, "button": { "shutdown": { - "name": "Shutdown" + "name": "Shut down" } }, "sensor": { @@ -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" }, @@ -248,14 +245,14 @@ "name": "Reboot" }, "shutdown": { - "description": "Shutdowns the NAS. This action is deprecated and will be removed in future release. Please use the corresponding button entity.", + "description": "Shuts down the NAS. This action is deprecated and will be removed in a future release. Please use the corresponding button entity.", "fields": { "serial": { - "description": "Serial of the NAS to shutdown; required when multiple NAS are configured.", + "description": "Serial of the NAS to shut down; required when multiple NAS are configured.", "name": "[%key:component::synology_dsm::services::reboot::fields::serial::name%]" } }, - "name": "Shutdown" + "name": "Shut down" } } } 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..f89caa72eb0 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -1,7 +1,5 @@ """The System Bridge integration.""" -from __future__ import annotations - import asyncio from dataclasses import asdict import logging @@ -21,7 +19,7 @@ from systembridgeconnector.models.open_url import OpenUrl from systembridgeconnector.version import Version import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( CONF_API_KEY, CONF_COMMAND, @@ -57,7 +55,24 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from .config_flow import SystemBridgeConfigFlow from .const import DATA_WAIT_TIMEOUT, DOMAIN, MODULES -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator + + +def _get_coordinator( + hass: HomeAssistant, entry_id: str +) -> SystemBridgeDataUpdateCoordinator: + """Return the coordinator for a config entry id.""" + entry: SystemBridgeConfigEntry | None = hass.config_entries.async_get_entry( + entry_id + ) + if entry is None or entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="device_not_found", + translation_placeholders={"device": entry_id}, + ) + return entry.runtime_data + _LOGGER = logging.getLogger(__name__) @@ -93,7 +108,7 @@ POWER_COMMAND_MAP = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, ) -> bool: """Set up System Bridge from a config entry.""" @@ -198,8 +213,7 @@ 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( @@ -216,7 +230,7 @@ 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], + {}, ) ) @@ -249,9 +263,7 @@ async def async_setup_entry( 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] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) processes: list[Process] = coordinator.data.processes # Find process.id from list, raise ServiceValidationError if not found @@ -275,9 +287,7 @@ async def async_setup_entry( ) -> 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] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) # Find processes from list items: list[dict[str, Any]] = [ @@ -295,9 +305,7 @@ async def async_setup_entry( 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] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) response = await coordinator.websocket_client.open_path( OpenPath(path=service_call.data[CONF_PATH]) ) @@ -306,9 +314,7 @@ async def async_setup_entry( 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] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) response = await getattr( coordinator.websocket_client, POWER_COMMAND_MAP[service_call.data[CONF_COMMAND]], @@ -318,9 +324,7 @@ async def async_setup_entry( 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] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) response = await coordinator.websocket_client.open_url( OpenUrl(url=service_call.data[CONF_URL]) ) @@ -328,9 +332,7 @@ async def async_setup_entry( 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] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) response = await coordinator.websocket_client.keyboard_keypress( KeyboardKey(key=service_call.data[CONF_KEY]) ) @@ -338,9 +340,7 @@ async def async_setup_entry( 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] - ] + coordinator = _get_coordinator(hass, service_call.data[CONF_BRIDGE]) response = await coordinator.websocket_client.keyboard_text( KeyboardText(text=service_call.data[CONF_TEXT]) ) @@ -446,33 +446,27 @@ 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: 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] ) 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/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..cc08011cfbe 100644 --- a/homeassistant/components/system_bridge/notify.py +++ b/homeassistant/components/system_bridge/notify.py @@ -1,7 +1,5 @@ """Support for System Bridge notification service.""" -from __future__ import annotations - import logging from typing import Any @@ -17,8 +15,7 @@ from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeConfigEntry, SystemBridgeDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -37,11 +34,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..6ac2a524f76 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, @@ -33,8 +30,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import UNDEFINED, 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 @@ -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]) diff --git a/homeassistant/components/system_bridge/update.py b/homeassistant/components/system_bridge/update.py index 12060c28669..a50f3694a83 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( [ 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/system_log/strings.json b/homeassistant/components/system_log/strings.json index e8bccb21438..27f512c41ac 100644 --- a/homeassistant/components/system_log/strings.json +++ b/homeassistant/components/system_log/strings.json @@ -12,11 +12,11 @@ }, "services": { "clear": { - "description": "Deletes all log entries.", - "name": "Clear" + "description": "Deletes all system log entries.", + "name": "Clear system log" }, "write": { - "description": "Write log entry.", + "description": "Writes a system log entry.", "fields": { "level": { "description": "Log level.", @@ -31,7 +31,7 @@ "name": "Message" } }, - "name": "Write" + "name": "Write to system log" } } } 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 225940e0d44..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 @@ -9,7 +7,7 @@ import os from typing import TYPE_CHECKING, Any, NamedTuple from psutil import Process -from psutil._common import sbattery, sdiskusage, shwtemp, snetio, snicaddr, sswap +from psutil._ntuples import sbattery, sdiskusage, shwtemp, snetio, snicaddr, sswap import psutil_home_assistant as ha_psutil from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN 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/manifest.json b/homeassistant/components/systemmonitor/manifest.json index c64fff86d10..444d5fb9596 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil-home-assistant==0.0.1", "psutil==7.1.2"], + "requirements": ["psutil-home-assistant==0.0.1", "psutil==7.2.2"], "single_config_entry": true } diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index fe57ada5318..4a4703235d3 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 diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 07790479c78..4fd5f9eaeb6 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -5,7 +5,7 @@ import os import re from typing import Any -from psutil._common import sfan, shwtemp +from psutil._ntuples import sfan, shwtemp import psutil_home_assistant as ha_psutil from homeassistant.core import HomeAssistant 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/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..968efafc536 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 diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index b161661f310..82dcf20ce71 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 @@ -81,6 +79,15 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): try: await self.hass.async_add_executor_job(self.tado.device_activation) except Exception as ex: + ratelimit = await self.hass.async_add_executor_job( + self.tado.rate_limit_info + ) + if ratelimit.get("remaining") == "0": + _LOGGER.error( + "Tado API rate limit reached while waiting for device activation: %s", + ex, + ) + raise TadoRateLimitExceeded from ex _LOGGER.exception("Error while waiting for device activation") raise CannotConnect from ex @@ -97,7 +104,10 @@ class TadoConfigFlow(ConfigFlow, domain=DOMAIN): if self.login_task.done(): _LOGGER.debug("Login task is done, checking results") - if self.login_task.exception(): + ex = self.login_task.exception() + if isinstance(ex, TadoRateLimitExceeded): + return self.async_abort(reason="api_rate_limit_reached") + if ex: return self.async_show_progress_done(next_step_id="timeout") self.refresh_token = await self.hass.async_add_executor_job( self.tado.get_refresh_token @@ -209,3 +219,7 @@ class OptionsFlowHandler(OptionsFlow): class CannotConnect(HomeAssistantError): """Error to indicate we cannot connect.""" + + +class TadoRateLimitExceeded(HomeAssistantError): + """Error to indicate Tado API rate limit exceeded.""" diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 44d7bbfe327..02bb0f2366c 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -1,10 +1,9 @@ """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 requests import RequestException @@ -33,7 +32,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,30 +66,45 @@ 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() + if ratelimit.get("remaining") == "0": + raise UpdateFailed(f"Tado API rate limit reached: {err}") from err raise UpdateFailed(f"Error during Tado setup: {err}") from err tado_home = tado_home_call["homes"][0] @@ -118,8 +132,75 @@ 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.""" @@ -362,3 +443,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): ) except RequestException as exc: raise HomeAssistantError(f"Error setting Tado child lock: {exc}") from exc + + def get_rate_limit(self) -> dict[str, str]: + """Get the current rate limit status from Tado.""" + return self._tado.rate_limit_info() diff --git a/homeassistant/components/tado/diagnostics.py b/homeassistant/components/tado/diagnostics.py index fa85b30c11c..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 @@ -14,4 +12,5 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a Tado config entry.""" - return {"data": config_entry.runtime_data.data} + rate_limit = config_entry.runtime_data.get_rate_limit() + return {"data": config_entry.runtime_data.data, "rate_limit": rate_limit} diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index bce88d52de0..4db8db2450d 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 diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 4d917f91f52..c9a931d8412 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "api_rate_limit_reached": "Tado API rate limit reached. Please wait and try again later.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "could_not_authenticate": "Could not authenticate with Tado.", "no_homes": "There are no homes linked to this Tado account.", 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..bc0609f4c14 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 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..311eea3c4f3 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 diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index dbd826b9359..28bf9ef7428 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 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/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/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..8b5d5a957eb 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 ( 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..3bc0c7e1cd0 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 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/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/coordinator.py b/homeassistant/components/tedee/coordinator.py index 693f6234873..81310097b97 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 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/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..fea653a5258 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: @@ -913,6 +929,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..f3e44428dfa 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -48,6 +48,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 ( @@ -302,6 +303,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, @@ -560,7 +562,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 +1014,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 +1053,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..596b2a65861 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, @@ -534,7 +534,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 +579,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 ) diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index 230b42f3040..7079cd2dc84 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" @@ -90,6 +91,7 @@ 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/manifest.json b/homeassistant/components/telegram_bot/manifest.json index 48bf0c3a270..3feb66ed4e7 100644 --- a/homeassistant/components/telegram_bot/manifest.json +++ b/homeassistant/components/telegram_bot/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["telegram"], "quality_scale": "gold", - "requirements": ["python-telegram-bot[socks]==22.6"] + "requirements": ["python-telegram-bot[socks]==22.7"] } diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index d3bb993376f..487d0d43a7f 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -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" + advanced: + 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..11673c647ff 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." }, @@ -951,6 +957,45 @@ } } }, + "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": { + "advanced": { + "name": "[%key:component::telegram_bot::services::send_message::sections::advanced::name%]" + } + } + }, "send_photo": { "description": "Sends a photo.", "fields": { 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..141f5c7b9a3 --- /dev/null +++ b/homeassistant/components/teleinfo/config_flow.py @@ -0,0 +1,128 @@ +"""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 + ) + + # Validate by reading a real Teleinfo frame — silent abort on failure + errors, decoded_data = await self._validate_serial_port(dev_path) + 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: dev_path}) + + 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: + 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..32b72035a52 --- /dev/null +++ b/homeassistant/components/teleinfo/manifest.json @@ -0,0 +1,22 @@ +{ + "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"], + "usb": [ + { + "pid": "6015", + "vid": "0403" + }, + { + "pid": "EA60", + "vid": "10C4" + } + ] +} 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..37d9d385fa5 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=hass-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..8414b669569 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=hass-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..d1f5e73a0a9 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=hass-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..b9a29b4d068 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=hass-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..1e2b022bdd6 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=hass-use-runtime-data client = hass.data[DOMAIN] async_add_entities([TelldusLiveSensor(client, device_id)]) diff --git a/homeassistant/components/tellduslive/switch.py b/homeassistant/components/tellduslive/switch.py index 346417f8989..47702d34ec0 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=hass-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..7b5195b80fe 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 diff --git a/homeassistant/components/teltonika/coordinator.py b/homeassistant/components/teltonika/coordinator.py index 7d1a614d141..8e1c239b1ef 100644 --- a/homeassistant/components/teltonika/coordinator.py +++ b/homeassistant/components/teltonika/coordinator.py @@ -1,7 +1,5 @@ """DataUpdateCoordinator for Teltonika.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import TYPE_CHECKING, Any diff --git a/homeassistant/components/teltonika/sensor.py b/homeassistant/components/teltonika/sensor.py index 623d73c987b..fddc22a2c76 100644 --- a/homeassistant/components/teltonika/sensor.py +++ b/homeassistant/components/teltonika/sensor.py @@ -1,7 +1,5 @@ """Teltonika sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from dataclasses import dataclass import logging 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/__init__.py b/homeassistant/components/temper/__init__.py index 587da1c6309..79cabf60928 100644 --- a/homeassistant/components/temper/__init__.py +++ b/homeassistant/components/temper/__init__.py @@ -1 +1 @@ -"""The temper component.""" +"""The TEMPer integration.""" 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 e1c74365759..d39b92e0f5e 100644 --- a/homeassistant/components/temperature/strings.json +++ b/homeassistant/components/temperature/strings.json @@ -1,53 +1,35 @@ { "common": { - "condition_behavior_description": "How the temperature should match on the targeted entities.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted entities to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "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": { "is_value": { "description": "Tests the temperature of one or more entities.", "fields": { "behavior": { - "description": "[%key:component::temperature::common::condition_behavior_description%]", "name": "[%key:component::temperature::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::temperature::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::temperature::common::condition_threshold_description%]", "name": "[%key:component::temperature::common::condition_threshold_name%]" } }, "name": "Temperature value" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Temperature", "triggers": { "changed": { "description": "Triggers after one or more temperatures change.", "fields": { "threshold": { - "description": "[%key:component::temperature::common::trigger_threshold_changed_description%]", "name": "[%key:component::temperature::common::trigger_threshold_name%]" } }, @@ -57,11 +39,12 @@ "description": "Triggers after one or more temperatures cross a threshold.", "fields": { "behavior": { - "description": "[%key:component::temperature::common::trigger_behavior_description%]", "name": "[%key:component::temperature::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::temperature::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::temperature::common::trigger_threshold_crossed_description%]", "name": "[%key:component::temperature::common::trigger_threshold_name%]" } }, diff --git a/homeassistant/components/temperature/trigger.py b/homeassistant/components/temperature/trigger.py index 79995349e66..a3a4aa9777c 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, @@ -48,6 +46,23 @@ class _TemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitBase): _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..7da401d4252 100644 --- a/homeassistant/components/temperature/triggers.yaml +++ b/homeassistant/components/temperature/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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..ba880009e86 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 @@ -206,7 +204,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] diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 90c0bb0a56f..8ef5828d97f 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 diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 8bccb47687d..83c2ea2d5c5 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 @@ -281,6 +279,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_flow.py b/homeassistant/components/template/config_flow.py index 52c6ac1ed2f..fd213b7a32c 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, @@ -180,18 +179,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 |= { @@ -288,6 +285,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), diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py index a2823233336..730f5615a49 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,7 +160,7 @@ 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", diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index e7cf443ee70..20db7a80e21 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 diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index f7b5c3ff989..951e2e19195 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -168,6 +168,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..4a75bff2f9e 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 diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 4e29a77f058..9bc34b9337c 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 diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 13388e90dcc..8c7dcac1fc1 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 diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 0ff0df03e71..8f937e96694 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 diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index cc527a4a050..53a438b1c44 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 diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 9dd62100917..aeb27467079 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, @@ -124,6 +129,7 @@ class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity): # 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..2f79a9055be 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 diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index febde76c6b0..dc37cfabec7 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 @@ -193,7 +191,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 @@ -265,6 +263,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..d7778fe03ea 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -292,6 +292,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", @@ -608,6 +609,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%]" }, @@ -835,6 +837,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 +1130,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 +1200,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 +1209,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 +1231,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..86710c9fdf7 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 diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index c98c740a9f3..52f59fd6243 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 diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 134c42bded1..e59ee1f05e8 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 @@ -30,6 +28,8 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module ): """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() @@ -204,6 +208,9 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module 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..ad1cefea9a8 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 diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index f06ae13141b..6ebb13e72eb 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,6 +16,7 @@ from homeassistant.components.vacuum import ( SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, + Segment, StateVacuumEntity, VacuumActivity, VacuumEntityFeature, @@ -59,12 +59,14 @@ 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_LIST = "fan_speeds" CONF_FAN_SPEED_TEMPLATE = "fan_speed_template" +CONF_SEGMENTS = "segments" +CONF_VACUUMS = "vacuums" DEFAULT_NAME = "Template Vacuum" @@ -77,6 +79,7 @@ LEGACY_FIELDS = { } SCRIPT_FIELDS = ( + CONF_CLEAN_SEGMENTS, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE, @@ -86,12 +89,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,15 +109,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_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_LEGACY_YAML_SCHEMA = vol.All( @@ -214,6 +232,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.""" @@ -228,6 +299,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): # 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 +317,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 +339,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..4ae865cda77 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,19 +25,12 @@ 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_NAME, - CONF_TEMPERATURE_UNIT, - CONF_UNIQUE_ID, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) +from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -232,18 +223,6 @@ 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 ) @@ -259,21 +238,23 @@ 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 - ) + _LOGGER.warning( + "Template weather entities can only be configured under template:" + ) + 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_to_modern_config( + hass, entity_config, LEGACY_FIELDS + ) - 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, diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py index f1acf192a32..5ea9ebc040f 100644 --- a/homeassistant/components/tesla_fleet/__init__.py +++ b/homeassistant/components/tesla_fleet/__init__.py @@ -16,7 +16,7 @@ from tesla_fleet_api.exceptions import ( from tesla_fleet_api.tesla import VehicleFleet from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform +from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -121,7 +121,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - ) raise ConfigEntryAuthFailed from e - access_token = entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] + oauth_session = OAuth2Session(hass, entry, implementation) + try: + await oauth_session.async_ensure_token_valid() + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed from err + except OAuth2TokenRequestError as err: + raise ConfigEntryNotReady from err + + access_token = oauth_session.token[CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) token = jwt.decode(access_token, options={"verify_signature": False}) @@ -129,8 +137,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) - region_code = token["ou_code"].lower() region = region_code if is_valid_region(region_code) else None - oauth_session = OAuth2Session(hass, entry, implementation) - async def _get_access_token() -> str: await oauth_session.async_ensure_token_valid() token: str = oauth_session.token[CONF_ACCESS_TOKEN] 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..f50ecf14bb9 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 diff --git a/homeassistant/components/tesla_fleet/config_flow.py b/homeassistant/components/tesla_fleet/config_flow.py index 14c197bc7c5..1830fc59db9 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 diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index 761bbebf7a8..6faf270f1fb 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 diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py index 397c11c524d..0fbe8117ace 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 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..1bb299d8d07 100644 --- a/homeassistant/components/tesla_fleet/device_tracker.py +++ b/homeassistant/components/tesla_fleet/device_tracker.py @@ -1,7 +1,5 @@ """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 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/lock.py b/homeassistant/components/tesla_fleet/lock.py index cdb1d4b066b..d1ebaaf7f6d 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 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 9d3787775a4..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 @@ -52,7 +50,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetNumberVehicleEntityDescription, ...] = ( mode=NumberMode.AUTO, max_key="charge_state_charge_current_request_max", func=lambda api, value: api.set_charging_amps(value), - scopes=[Scope.VEHICLE_CHARGING_CMDS], + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], ), TeslaFleetNumberVehicleEntityDescription( key="charge_state_charge_limit_soc", diff --git a/homeassistant/components/tesla_fleet/oauth.py b/homeassistant/components/tesla_fleet/oauth.py index b25c5216009..93d8e792e92 100644 --- a/homeassistant/components/tesla_fleet/oauth.py +++ b/homeassistant/components/tesla_fleet/oauth.py @@ -30,4 +30,8 @@ class TeslaUserImplementation(AuthImplementation): @property def extra_authorize_data(self) -> dict[str, Any]: """Extra data that needs to be appended to the authorize url.""" - return {"prompt": "login", "scope": " ".join(SCOPES)} + return { + "prompt": "login", + "prompt_missing_scopes": "true", + "scope": " ".join(SCOPES), + } 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..b33a8e1fb2f 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 diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 14927768331..3e36a827e5c 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -50,7 +50,7 @@ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, "reauth_confirm": { - "description": "The {name} integration needs to re-authenticate your account", + "description": "The {name} integration needs to re-authenticate your account. Reauthentication refreshes the Tesla API permissions granted to Home Assistant, including any newly enabled scopes.", "title": "[%key:common::config_flow::title::reauth%]" }, "registration_complete": { @@ -60,7 +60,7 @@ "data_description": { "qr_code": "Scan this QR code with your phone to set up the virtual key." }, - "description": "To enable command signing, you must open the Tesla app, select your vehicle, and then visit the following URL to set up a virtual key. You must repeat this process for each vehicle.\n\n{virtual_key_url}", + "description": "To enable command signing, you must open the Tesla app, select your vehicle, and then visit the following URL to set up a virtual key. You must repeat this process for each vehicle.\n\n{virtual_key_url}\n\nIf you later enable additional Tesla API permissions, reauthenticate the integration to refresh the granted scopes.", "title": "Command signing" } } 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..08a981b3c07 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,7 +84,7 @@ 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: raise UpdateFailed( @@ -136,7 +135,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 +185,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 +232,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 +278,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..22570720980 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 diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index 84be1d742dc..c6bb6bca62e 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 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/lock.py b/homeassistant/components/teslemetry/lock.py index 7e98d6338ba..05822a3d632 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 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..e5e01d5466f 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 diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index bfe2ed08eeb..d0d2e047018 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 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 2077b05cdc5..684a5eb9336 100644 --- a/homeassistant/components/tessie/__init__.py +++ b/homeassistant/components/tessie/__init__.py @@ -1,19 +1,21 @@ """Tessie integration.""" import asyncio -from http import HTTPStatus import logging -from aiohttp import ClientError, ClientResponseError from tesla_fleet_api.const import Scope from tesla_fleet_api.exceptions import ( Forbidden, + GatewayTimeout, + InvalidResponse, InvalidToken, + MissingToken, + RateLimited, + ServiceUnavailable, SubscriptionRequired, TeslaFleetError, ) from tesla_fleet_api.tessie import Tessie -from tessie_api import get_state_of_all_vehicles from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform @@ -54,57 +56,70 @@ _LOGGER = logging.getLogger(__name__) type TessieConfigEntry = ConfigEntry[TessieData] +RETRY_EXCEPTIONS = ( + InvalidResponse, + RateLimited, + ServiceUnavailable, + GatewayTimeout, +) + async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bool: """Set up Tessie config.""" api_key = entry.data[CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) + tessie = Tessie(session, api_key) try: - state_of_all_vehicles = await get_state_of_all_vehicles( - session=session, - api_key=api_key, - only_active=True, - ) - except ClientResponseError as e: - if e.status == HTTPStatus.UNAUTHORIZED: - raise ConfigEntryAuthFailed from e - raise ConfigEntryError("Setup failed, unable to connect to Tessie") from e - except ClientError as e: + state_of_all_vehicles = await tessie.list_vehicles(only_active=True) + except (InvalidToken, MissingToken) as e: + raise ConfigEntryAuthFailed from e + except RETRY_EXCEPTIONS as e: raise ConfigEntryNotReady from e + except TeslaFleetError as e: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from e - vehicles = [ - TessieVehicleData( - vin=vehicle["vin"], - data_coordinator=TessieStateUpdateCoordinator( - hass, - entry, - api_key=api_key, - vin=vehicle["vin"], - data=vehicle["last_state"], - ), - device=DeviceInfo( - identifiers={(DOMAIN, vehicle["vin"])}, - manufacturer="Tesla", - configuration_url="https://my.tessie.com/", - name=vehicle["last_state"]["display_name"], - model=MODELS.get( - vehicle["last_state"]["vehicle_config"]["car_type"], - vehicle["last_state"]["vehicle_config"]["car_type"], + vehicles: list[TessieVehicleData] = [] + for vehicle in state_of_all_vehicles["results"]: + if vehicle["last_state"] is None: + continue + + vin = vehicle["vin"] + vehicle_api = tessie.vehicles.create(vin) + vehicles.append( + TessieVehicleData( + api=vehicle_api, + vin=vin, + data_coordinator=TessieStateUpdateCoordinator( + hass, + entry, + api=vehicle_api, + api_key=api_key, + vin=vin, + data=vehicle["last_state"], ), - sw_version=vehicle["last_state"]["vehicle_state"]["car_version"].split( - " " - )[0], - hw_version=vehicle["last_state"]["vehicle_config"]["driver_assist"], - serial_number=vehicle["vin"], - ), + device=DeviceInfo( + identifiers={(DOMAIN, vin)}, + manufacturer="Tesla", + configuration_url="https://my.tessie.com/", + name=vehicle["last_state"]["display_name"], + model=MODELS.get( + vehicle["last_state"]["vehicle_config"]["car_type"], + vehicle["last_state"]["vehicle_config"]["car_type"], + ), + sw_version=vehicle["last_state"]["vehicle_state"][ + "car_version" + ].split(" ")[0], + hw_version=vehicle["last_state"]["vehicle_config"]["driver_assist"], + serial_number=vin, + ), + ) ) - for vehicle in state_of_all_vehicles["results"] - if vehicle["last_state"] is not None - ] # Energy Sites - tessie = Tessie(session, api_key) energysites: list[TessieEnergyData] = [] try: 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 14c6b93fdfd..1ee7ad6ab08 100644 --- a/homeassistant/components/tessie/config_flow.py +++ b/homeassistant/components/tessie/config_flow.py @@ -1,17 +1,16 @@ """Config Flow for Tessie integration.""" -from __future__ import annotations - from collections.abc import Mapping -from http import HTTPStatus from typing import Any -from aiohttp import ClientConnectionError, ClientResponseError -from tessie_api import get_state_of_all_vehicles +from aiohttp import ClientConnectionError +from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError +from tesla_fleet_api.tessie import Tessie import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -23,6 +22,24 @@ DESCRIPTION_PLACEHOLDERS = { } +async def _async_validate_access_token( + hass: HomeAssistant, access_token: str, *, only_active: bool = False +) -> dict[str, str]: + """Validate a Tessie access token.""" + try: + await Tessie(async_get_clientsession(hass), access_token).list_vehicles( + only_active=only_active + ) + except InvalidToken, MissingToken: + return {CONF_ACCESS_TOKEN: "invalid_access_token"} + except ClientConnectionError: + return {"base": "cannot_connect"} + except TeslaFleetError: + return {"base": "unknown"} + + return {} + + class TessieConfigFlow(ConfigFlow, domain=DOMAIN): """Config Tessie API connection.""" @@ -35,20 +52,10 @@ class TessieConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input: self._async_abort_entries_match(dict(user_input)) - try: - await get_state_of_all_vehicles( - session=async_get_clientsession(self.hass), - api_key=user_input[CONF_ACCESS_TOKEN], - only_active=True, - ) - except ClientResponseError as e: - if e.status == HTTPStatus.UNAUTHORIZED: - errors[CONF_ACCESS_TOKEN] = "invalid_access_token" - else: - errors["base"] = "unknown" - except ClientConnectionError: - errors["base"] = "cannot_connect" - else: + errors = await _async_validate_access_token( + self.hass, user_input[CONF_ACCESS_TOKEN], only_active=True + ) + if not errors: return self.async_create_entry( title="Tessie", data=user_input, @@ -74,19 +81,10 @@ class TessieConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input: - try: - await get_state_of_all_vehicles( - session=async_get_clientsession(self.hass), - api_key=user_input[CONF_ACCESS_TOKEN], - ) - except ClientResponseError as e: - if e.status == HTTPStatus.UNAUTHORIZED: - errors[CONF_ACCESS_TOKEN] = "invalid_access_token" - else: - errors["base"] = "unknown" - except ClientConnectionError: - errors["base"] = "cannot_connect" - else: + errors = await _async_validate_access_token( + self.hass, user_input[CONF_ACCESS_TOKEN] + ) + if not errors: return self.async_update_reload_and_abort( self._get_reauth_entry(), data=user_input ) 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 2a0c0e07f94..4ea613bd578 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 @@ -10,8 +8,7 @@ from typing import TYPE_CHECKING, Any from aiohttp import ClientError, ClientResponseError from tesla_fleet_api.const import TeslaEnergyPeriod from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError -from tesla_fleet_api.tessie import EnergySite -from tessie_api import get_state +from tesla_fleet_api.tessie import EnergySite, Vehicle from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -54,6 +51,7 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): self, hass: HomeAssistant, config_entry: TessieConfigEntry, + api: Vehicle, api_key: str, vin: str, data: dict[str, Any], @@ -66,6 +64,7 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): name="Tessie", update_interval=timedelta(seconds=TESSIE_SYNC_INTERVAL), ) + self.api = api self.api_key = api_key self.vin = vin self.session = async_get_clientsession(hass) @@ -74,12 +73,14 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Tessie API.""" try: - vehicle = await get_state( - session=self.session, - api_key=self.api_key, - vin=self.vin, - use_cache=True, - ) + vehicle = await self.api.state(use_cache=True) + except (InvalidToken, MissingToken) as e: + raise ConfigEntryAuthFailed from e + except TeslaFleetError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from e except ClientResponseError as e: if e.status == HTTPStatus.UNAUTHORIZED: raise ConfigEntryAuthFailed from e @@ -221,10 +222,11 @@ 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 98a424eefc1..20abf3f9750 100644 --- a/homeassistant/components/tessie/entity.py +++ b/homeassistant/components/tessie/entity.py @@ -2,21 +2,20 @@ from abc import abstractmethod from collections.abc import Awaitable, Callable +from inspect import isawaitable from typing import Any -from aiohttp import ClientError - -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, TRANSLATED_ERRORS +from .const import DOMAIN from .coordinator import ( TessieEnergyHistoryCoordinator, TessieEnergySiteInfoCoordinator, TessieEnergySiteLiveCoordinator, TessieStateUpdateCoordinator, ) +from .helpers import handle_command, handle_legacy_command from .models import TessieEnergyData, TessieVehicleData @@ -78,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 @@ -93,30 +93,24 @@ class TessieEntity(TessieBaseEntity): self.async_write_ha_state() async def run( - self, func: Callable[..., Awaitable[dict[str, Any]]], **kargs: Any + self, + command: Callable[..., Awaitable[dict[str, Any]]] | Awaitable[dict[str, Any]], + **kargs: Any, ) -> None: - """Run a tessie_api function and handle exceptions.""" - try: - response = await func( + """Run a legacy tessie_api command function or awaitable Vehicle command.""" + if isawaitable(command): + await handle_command(command) + return + + await handle_legacy_command( + command( session=self._session, vin=self.vin, api_key=self._api_key, **kargs, - ) - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="cannot_connect", - ) from e - if response["result"] is False: - name: str = getattr(self, "name", self.entity_id) - reason: str = response.get("reason", "unknown") - translation_key = TRANSLATED_ERRORS.get(reason, "command_failed") - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key=translation_key, - translation_placeholders={"name": name, "message": reason}, - ) + ), + name=getattr(self, "name", self.entity_id), + ) def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" diff --git a/homeassistant/components/tessie/helpers.py b/homeassistant/components/tessie/helpers.py index 41e619ac10d..c37a9f4d0f6 100644 --- a/homeassistant/components/tessie/helpers.py +++ b/homeassistant/components/tessie/helpers.py @@ -1,19 +1,40 @@ """Tessie helper functions.""" +from collections.abc import Awaitable from typing import Any +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 +from .const import DOMAIN, TRANSLATED_ERRORS, TessieChargeStates -async def handle_command(command) -> dict[str, Any]: - """Handle a command.""" +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, @@ -22,3 +43,22 @@ async def handle_command(command) -> dict[str, Any]: ) from e _LOGGER.debug("Command result: %s", result) return result + + +async def handle_legacy_command(command: Awaitable[dict[str, Any]], name: str) -> None: + """Handle a legacy tessie_api command result.""" + try: + response = await command + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from e + if response["result"] is False: + reason: str = response.get("reason", "unknown") + translation_key = TRANSLATED_ERRORS.get(reason, "command_failed") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key=translation_key, + translation_placeholders={"name": name, "message": reason}, + ) 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 e4e4bb34e81..7ca218e4ef2 100644 --- a/homeassistant/components/tessie/models.py +++ b/homeassistant/components/tessie/models.py @@ -1,10 +1,8 @@ """The Tessie integration models.""" -from __future__ import annotations - from dataclasses import dataclass -from tesla_fleet_api.tessie import EnergySite +from tesla_fleet_api.tessie import EnergySite, Vehicle from homeassistant.helpers.device_registry import DeviceInfo @@ -40,6 +38,7 @@ class TessieEnergyData: class TessieVehicleData: """Data for a Tessie vehicle.""" + api: Vehicle data_coordinator: TessieStateUpdateCoordinator device: DeviceInfo vin: str 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/select.py b/homeassistant/components/tessie/select.py index ce907deb9c8..67b0defeafb 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 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..c6fffd1e49a 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 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/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 e7fea2f230e..2d4b6f03a80 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -1,16 +1,18 @@ { "common": { - "condition_behavior_description": "The behavior of the targeted texts to check.", - "condition_behavior_name": "Behavior" + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least" }, "conditions": { "is_equal_to": { "description": "Tests if one or more texts are equal to a specified value.", "fields": { "behavior": { - "description": "[%key:component::text::common::condition_behavior_description%]", "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" @@ -50,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..1ede394a70c 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 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..9e61c0dd750 100644 --- a/homeassistant/components/thermobeacon/sensor.py +++ b/homeassistant/components/thermobeacon/sensor.py @@ -1,7 +1,5 @@ """Support for ThermoBeacon sensors.""" -from __future__ import annotations - from thermobeacon_ble import ( SensorDeviceClass as ThermoBeaconSensorDeviceClass, SensorUpdate, @@ -116,6 +114,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the ThermoBeacon BLE sensors.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] diff --git a/homeassistant/components/thermopro/__init__.py b/homeassistant/components/thermopro/__init__.py index 742449cffbe..d0a7724a993 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 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..f919c8dc5a1 100644 --- a/homeassistant/components/thermopro/config_flow.py +++ b/homeassistant/components/thermopro/config_flow.py @@ -1,7 +1,5 @@ """Config flow for thermopro ble integration.""" -from __future__ import annotations - from typing import Any from thermopro_ble import ThermoProBluetoothDeviceData as DeviceData diff --git a/homeassistant/components/thermopro/sensor.py b/homeassistant/components/thermopro/sensor.py index bc077462784..d49b255b8fe 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=hass-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..ae216118b45 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=hass-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..95cbb2f19e7 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 diff --git a/homeassistant/components/thread/diagnostics.py b/homeassistant/components/thread/diagnostics.py index c66aec3bac9..8b8ae9c2ec7 100644 --- a/homeassistant/components/thread/diagnostics.py +++ b/homeassistant/components/thread/diagnostics.py @@ -15,8 +15,7 @@ This does not do any connectivity checks. So user could have all their border ro 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 from python_otbr_api.tlv_parser import MeshcopTLVType @@ -147,8 +146,11 @@ async def async_get_config_entry_diagnostics( }, ) if mlp_item := record.dataset.get(MeshcopTLVType.MESHLOCALPREFIX): - mlp = str(mlp_item) - network["prefixes"].add(f"{mlp[0:4]}:{mlp[4:8]}:{mlp[8:12]}:{mlp[12:16]}") + # 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 # border routers as we process the zeroconf data. diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 4709162ee4b..e4a2885ce5c 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 diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index a00f7480ede..aa4d90c9e1a 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.7.5"], "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..2c1777f9759 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 diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 40a882a5b04..7ae66388cfc 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,6 +42,9 @@ 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_client(self, hass: HomeAssistant) -> tibber.Tibber: @@ -55,7 +61,7 @@ class TibberRuntimeData: time_zone=dt_util.get_default_time_zone(), ssl=ssl_util.get_default_context(), ) - self._client.set_access_token(access_token) + await self._client.set_access_token(access_token) return self._client @@ -124,6 +130,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 14f4f26a81b..9877b62f369 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.36.0"] + "requirements": ["pyTibber==0.37.5"] } 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..7e7483b5eb6 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]): 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 9ac2cb91aff..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 @@ -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 TiltBLEConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: TiltBLEConfigEntry) -> bool: """Set up Tilt BLE device from a config entry.""" address = entry.unique_id assert address is not None data = TiltBluetoothDeviceData() - 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: TiltBLEConfigEntry) -> 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/tilt_ble/config_flow.py b/homeassistant/components/tilt_ble/config_flow.py index b4a3235c60f..36acfc652d0 100644 --- a/homeassistant/components/tilt_ble/config_flow.py +++ b/homeassistant/components/tilt_ble/config_flow.py @@ -1,7 +1,5 @@ """Config flow for tilt_ble.""" -from __future__ import annotations - from typing import Any from tilt_ble import TiltBluetoothDeviceData as DeviceData diff --git a/homeassistant/components/tilt_ble/sensor.py b/homeassistant/components/tilt_ble/sensor.py index 411484cf2fe..8e347c0632d 100644 --- a/homeassistant/components/tilt_ble/sensor.py +++ b/homeassistant/components/tilt_ble/sensor.py @@ -1,15 +1,11 @@ """Support for Tilt Hydrometers.""" -from __future__ import annotations - from tilt_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 ( @@ -23,7 +19,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 TiltBLEConfigEntry SENSOR_DESCRIPTIONS = { (DeviceClass.TEMPERATURE, Units.TEMP_FAHRENHEIT): SensorEntityDescription( @@ -85,13 +81,11 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: TiltBLEConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Tilt Hydrometer 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( 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/strings.json b/homeassistant/components/time/strings.json index e22b3b325b8..463e6c8d1d1 100644 --- a/homeassistant/components/time/strings.json +++ b/homeassistant/components/time/strings.json @@ -6,14 +6,14 @@ }, "services": { "set_value": { - "description": "Sets the time.", + "description": "Sets the value of a time entity.", "fields": { "time": { "description": "The time to set.", "name": "Time" } }, - "name": "Set Time" + "name": "Set time" } }, "title": "Time" 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..6591823144a 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 diff --git a/homeassistant/components/time_date/const.py b/homeassistant/components/time_date/const.py index 53656bae181..2163fe76bdb 100644 --- a/homeassistant/components/time_date/const.py +++ b/homeassistant/components/time_date/const.py @@ -1,7 +1,5 @@ """Constants for the Time & Date integration.""" -from __future__ import annotations - from typing import Final from homeassistant.const import Platform 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..c77f5d0df3c 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 @@ -349,6 +350,8 @@ class Timer(collection.CollectionEntity, RestoreEntity): 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 +369,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 +384,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 +402,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 +418,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 +430,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 0b54a62f68b..ddc6e9db44f 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -1,15 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted timers.", - "condition_behavior_name": "Behavior" + "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": { "description": "Tests if one or more timers are active.", "fields": { "behavior": { - "description": "[%key:component::timer::common::condition_behavior_description%]", "name": "[%key:component::timer::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::condition_for_name%]" } }, "name": "Timer is active" @@ -18,8 +22,10 @@ "description": "Tests if one or more timers are idle.", "fields": { "behavior": { - "description": "[%key:component::timer::common::condition_behavior_description%]", "name": "[%key:component::timer::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::condition_for_name%]" } }, "name": "Timer is idle" @@ -28,8 +34,10 @@ "description": "Tests if one or more timers are paused.", "fields": { "behavior": { - "description": "[%key:component::timer::common::condition_behavior_description%]", "name": "[%key:component::timer::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::condition_for_name%]" } }, "name": "Timer is paused" @@ -57,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" }, @@ -66,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.", @@ -112,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..a94482202b8 --- /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: any + 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..56380bf1cbe 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -1,7 +1,5 @@ """Support for TMB (Transports Metropolitans de Barcelona) Barcelona public transport.""" -from __future__ import annotations - from datetime import timedelta import logging from typing import Any 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 883f7fac6f1..38d68576b89 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 @@ -91,7 +89,7 @@ class ListAddItemIntentHandler(ListBaseIntentHandler): # Add to list await target_list.async_create_todo_item( - TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION) + TodoItem(summary=item.capitalize(), status=TodoItemStatus.NEEDS_ACTION) ) @@ -108,9 +106,9 @@ class ListCompleteItemIntentHandler(ListBaseIntentHandler): matching_item = None for todo_item in target_list.todo_items or (): if ( - item in (todo_item.uid, todo_item.summary) - and todo_item.status == TodoItemStatus.NEEDS_ACTION - ): + item == todo_item.uid + or item.casefold() == (todo_item.summary or "").casefold() + ) and todo_item.status == TodoItemStatus.NEEDS_ACTION: matching_item = todo_item break if not matching_item or not matching_item.uid: @@ -140,7 +138,10 @@ class ListRemoveItemIntentHandler(ListBaseIntentHandler): # Find item in list matching_item = None for todo_item in target_list.todo_items or (): - if item in (todo_item.uid, todo_item.summary): + if ( + item == todo_item.uid + or item.casefold() == (todo_item.summary or "").casefold() + ): matching_item = todo_item break if not matching_item or not matching_item.uid: 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..cdb3bd5dd64 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={}): {}, } ) 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..216f2a9ea4b 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() diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py index 41e7602836e..6dcf886b49c 100644 --- a/homeassistant/components/todoist/coordinator.py +++ b/homeassistant/components/todoist/coordinator.py @@ -15,6 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import MAX_PAGE_SIZE +type TodoistConfigEntry = ConfigEntry[TodoistCoordinator] + T = TypeVar("T") 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..ef58da16aac 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 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..81dd8e6bb7e 100644 --- a/homeassistant/components/togrill/select.py +++ b/homeassistant/components/togrill/select.py @@ -1,7 +1,5 @@ """Support for select entities.""" -from __future__ import annotations - from collections.abc import Callable, Generator, Mapping from dataclasses import dataclass from enum import Enum 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/__init__.py b/homeassistant/components/tomato/__init__.py index e8a67f7e3bc..4441481d034 100644 --- a/homeassistant/components/tomato/__init__.py +++ b/homeassistant/components/tomato/__init__.py @@ -1 +1 @@ -"""The tomato component.""" +"""The Tomato integration.""" 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..1f7b7e4173d 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=hass-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..bf5e4ac7843 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=hass-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..0cc85b5c62c 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=hass-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..74e5368f128 100644 --- a/homeassistant/components/toon/helpers.py +++ b/homeassistant/components/toon/helpers.py @@ -1,7 +1,5 @@ """Helpers for Toon.""" -from __future__ import annotations - from collections.abc import Callable, Coroutine import logging from typing import Any, Concatenate 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/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..50a1de6b663 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=hass-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/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..387e51032f4 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 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..112b1b667bb 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 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..c40223b65a6 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 diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index 6956f975908..a20bf142cd5 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 diff --git a/homeassistant/components/tplink_omada/coordinator.py b/homeassistant/components/tplink_omada/coordinator.py index 8191a47c6c7..0e4b92ea200 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 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/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 3a68dfe91bf..4e348ecb1cf 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["tplink-omada-client==1.5.6"] + "requirements": ["tplink-omada-client==1.5.7"] } 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..13376c0fd2f 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 diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py index 26149d779ea..9dd1fff2b15 100644 --- a/homeassistant/components/tplink_omada/switch.py +++ b/homeassistant/components/tplink_omada/switch.py @@ -1,7 +1,5 @@ """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 diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py index 0c3112d4a9c..d6c376549bc 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 diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index f3138a113c4..c1d6a7df0c7 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=hass-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..a728a03a960 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 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 09d85520dfb..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 @@ -14,7 +12,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfLength, UnitOfSpeed +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricPotential, + UnitOfLength, + UnitOfSpeed, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -45,6 +49,26 @@ TRACCAR_SERVER_SENSOR_ENTITY_DESCRIPTIONS: tuple[ suggested_display_precision=0, value_fn=lambda x: x["attributes"].get("batteryLevel"), ), + TraccarServerSensorEntityDescription[PositionModel]( + key="attributes.power", + data_key="position", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda x: x["attributes"].get("power"), + translation_key="power", + ), + TraccarServerSensorEntityDescription[PositionModel]( + key="attributes.battery", + data_key="position", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=2, + value_fn=lambda x: x["attributes"].get("battery"), + translation_key="battery", + ), TraccarServerSensorEntityDescription[PositionModel]( key="speed", data_key="position", diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json index c3571d251b5..514636e105c 100644 --- a/homeassistant/components/traccar_server/strings.json +++ b/homeassistant/components/traccar_server/strings.json @@ -55,8 +55,14 @@ "altitude": { "name": "Altitude" }, + "battery": { + "name": "Battery voltage" + }, "geofence": { "name": "Geofence" + }, + "power": { + "name": "Supply voltage" } } }, 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..67a4b477a68 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. @@ -164,12 +168,11 @@ 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( @@ -246,6 +249,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 +306,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..ce107c33d7c 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 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..59db41d865e 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 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..27e51285f72 100644 --- a/homeassistant/components/tractive/entity.py +++ b/homeassistant/components/tractive/entity.py @@ -1,7 +1,5 @@ """A entity class for Tractive integration.""" -from __future__ import annotations - from typing import Any from homeassistant.core import callback 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..0740ae2c368 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 diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py index 0f05a20c0ec..728dfb94379 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 @@ -100,13 +98,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() diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index c3e8938b244..bcacbdb38c1 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 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..79e303408e8 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 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..4eb05fe3362 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 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..c737ce9d386 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 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..d37f0a22fd2 100644 --- a/homeassistant/components/trafikverket_train/util.py +++ b/homeassistant/components/trafikverket_train/util.py @@ -1,7 +1,5 @@ """Utils for trafikverket_train.""" -from __future__ import annotations - from datetime import date, timedelta from homeassistant.const import WEEKDAYS 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 c9eff349f7f..700d0df89b1 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,13 +36,14 @@ 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__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.EVENT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.EVENT, Platform.SENSOR, Platform.SWITCH] MIGRATION_NAME_TO_KEY = { # Sensors @@ -97,6 +100,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/binary_sensor.py b/homeassistant/components/transmission/binary_sensor.py deleted file mode 100644 index a00291eb3ec..00000000000 --- a/homeassistant/components/transmission/binary_sensor.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Binary sensor platform for Transmission integration.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass - -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 TransmissionConfigEntry, TransmissionDataUpdateCoordinator -from .entity import TransmissionEntity - -PARALLEL_UPDATES = 0 - - -@dataclass(frozen=True, kw_only=True) -class TransmissionBinarySensorEntityDescription(BinarySensorEntityDescription): - """Describe a Transmission binary sensor entity.""" - - is_on_fn: Callable[[TransmissionDataUpdateCoordinator], bool | None] - - -BINARY_SENSOR_TYPES: tuple[TransmissionBinarySensorEntityDescription, ...] = ( - TransmissionBinarySensorEntityDescription( - key="port_forwarding", - translation_key="port_forwarding", - device_class=BinarySensorDeviceClass.CONNECTIVITY, - entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda coordinator: coordinator.port_forwarding, - entity_registry_enabled_default=False, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: TransmissionConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up Transmission binary sensors from a config entry.""" - coordinator = config_entry.runtime_data - - async_add_entities( - TransmissionBinarySensor(coordinator, description) - for description in BINARY_SENSOR_TYPES - ) - - -class TransmissionBinarySensor(TransmissionEntity, BinarySensorEntity): - """Representation of a Transmission binary sensor.""" - - entity_description: TransmissionBinarySensorEntityDescription - - @property - def is_on(self) -> bool | None: - """Return True if the port is open.""" - return self.entity_description.is_on_fn(self.coordinator) 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 56f4f7666cd..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 @@ -65,7 +63,6 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): self.api = api self.host = entry.data[CONF_HOST] self._session: transmission_rpc.Session | None = None - self.port_forwarding: bool | None = None self._all_torrents: list[transmission_rpc.Torrent] = [] self._completed_torrents: list[transmission_rpc.Torrent] = [] self._started_torrents: list[transmission_rpc.Torrent] = [] @@ -122,7 +119,6 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): data = self.api.session_stats() self.torrents = self.api.get_torrents() self._session = self.api.get_session() - self.port_forwarding = self.api.port_test() except transmission_rpc.TransmissionError as err: raise UpdateFailed("Unable to connect to Transmission client") from err 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/icons.json b/homeassistant/components/transmission/icons.json index 1d5b149487e..9cc3b1dfad3 100644 --- a/homeassistant/components/transmission/icons.json +++ b/homeassistant/components/transmission/icons.json @@ -1,13 +1,5 @@ { "entity": { - "binary_sensor": { - "port_forwarding": { - "default": "mdi:shield-check", - "state": { - "off": "mdi:shield-off" - } - } - }, "event": { "torrent": { "default": "mdi:folder-file-outline" 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 c7ec6ea7426..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": { @@ -41,15 +42,6 @@ } }, "entity": { - "binary_sensor": { - "port_forwarding": { - "name": "Port forwarding", - "state": { - "off": "Closed", - "on": "Open" - } - } - }, "event": { "torrent": { "name": "Torrent", @@ -131,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..e8cbcbf679b 100644 --- a/homeassistant/components/trend/config_flow.py +++ b/homeassistant/components/trend/config_flow.py @@ -1,7 +1,5 @@ """Config flow for Trend integration.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast diff --git a/homeassistant/components/triggercmd/__init__.py b/homeassistant/components/triggercmd/__init__.py index 3c1a2c855d0..19b7bdf5409 100644 --- a/homeassistant/components/triggercmd/__init__.py +++ b/homeassistant/components/triggercmd/__init__.py @@ -1,7 +1,5 @@ """The TRIGGERcmd component.""" -from __future__ import annotations - from triggercmd import client, ha from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/triggercmd/config_flow.py b/homeassistant/components/triggercmd/config_flow.py index e796e836abf..83828a72f3b 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 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..c993bb4d5a4 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 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/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/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 0555f8a145a..3e9bf9c6cc6 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,17 +57,8 @@ 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 @@ -133,10 +86,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 +107,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..2537f233ad6 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] = { @@ -91,7 +89,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 91ae00da39f..98186e3750a 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 @@ -74,6 +72,14 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, .. TAMPER_BINARY_SENSOR, ), DeviceCategory.CS: ( + TuyaBinarySensorEntityDescription( + key=f"{DPCode.FAULT}_water_full", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="water_full", + translation_key="tankfull", + ), TuyaBinarySensorEntityDescription( key="tankfull", dpcode=DPCode.FAULT, @@ -426,7 +432,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..02e571b5ee7 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -1,9 +1,7 @@ """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 @@ -14,8 +12,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 BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = { @@ -106,7 +104,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 3790f470b78..47f332d027c 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -1,27 +1,29 @@ """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 from homeassistant.components import ffmpeg -from homeassistant.components.camera import Camera as CameraEntity, CameraEntityFeature +from homeassistant.components.camera import ( + Camera as CameraEntity, + CameraEntityDescription, + CameraEntityFeature, +) 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: tuple[DeviceCategory, ...] = ( - DeviceCategory.DGHSXJ, - DeviceCategory.SP, -) +CAMERAS: dict[DeviceCategory, CameraEntityDescription] = { + DeviceCategory.DGHSXJ: CameraEntityDescription(key=""), + DeviceCategory.SP: CameraEntityDescription(key=""), +} async def async_setup_entry( @@ -38,9 +40,11 @@ async def async_setup_entry( entities: list[TuyaCameraEntity] = [] for device_id in device_ids: device = manager.device_map[device_id] - if device.category in CAMERAS: + if description := CAMERAS.get(device.category): entities.append( - TuyaCameraEntity(device, manager, get_default_definition(device)) + TuyaCameraEntity( + device, manager, description, get_default_definition(device) + ) ) async_add_entities(entities) @@ -63,10 +67,11 @@ class TuyaCameraEntity(TuyaEntity, CameraEntity): self, device: CustomerDevice, device_manager: Manager, - definition: TuyaCameraDefinition, + description: CameraEntityDescription, + definition: CameraDefinition, ) -> None: """Init Tuya Camera.""" - super().__init__(device, device_manager) + super().__init__(device, device_manager, description) CameraEntity.__init__(self) self._attr_model = device.product_name self._motion_detection_switch = definition.motion_detection_switch diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 06db6ea9f4b..7ade4f5d71e 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 ( @@ -32,8 +30,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_HVACMODE_MAPPINGS = { @@ -144,7 +142,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): device: CustomerDevice, device_manager: Manager, description: TuyaClimateEntityDescription, - definition: TuyaClimateDefinition, + definition: ClimateDefinition, ) -> None: """Determine which values to use.""" super().__init__(device, device_manager, description) 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..ff668ccd5d9 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 diff --git a/homeassistant/components/tuya/coordinator.py b/homeassistant/components/tuya/coordinator.py new file mode 100644 index 00000000000..6b8cf501d4f --- /dev/null +++ b/homeassistant/components/tuya/coordinator.py @@ -0,0 +1,198 @@ +"""Support for Tuya Smart devices.""" + +from pathlib import Path +from typing import Any + +from tuya_device_handlers.devices import TUYA_QUIRKS_REGISTRY, 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 33c729e9179..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): @@ -24,28 +22,16 @@ class TuyaEntity(Entity): self, device: CustomerDevice, device_manager: Manager, - description: EntityDescription | None = None, + description: EntityDescription, ) -> None: - """Init TuyaHaEntity.""" - self._attr_unique_id = f"tuya.{device.id}" + """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 device.set_up = True self.device = device self.device_manager = device_manager - if description: - self._attr_unique_id = f"tuya.{device.id}{description.key}" - self.entity_description = description - - @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: 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 baf3b74e71a..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 @@ -15,23 +10,24 @@ from homeassistant.components.fan import ( DIRECTION_FORWARD, DIRECTION_REVERSE, FanEntity, + FanEntityDescription, FanEntityFeature, ) 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_SUPPORT_TYPE: set[DeviceCategory] = { - DeviceCategory.CS, - DeviceCategory.FS, - DeviceCategory.FSD, - DeviceCategory.FSKG, - DeviceCategory.KJ, - DeviceCategory.KS, +FANS: dict[DeviceCategory, FanEntityDescription] = { + DeviceCategory.CS: FanEntityDescription(key=""), + DeviceCategory.FS: FanEntityDescription(key=""), + DeviceCategory.FSD: FanEntityDescription(key=""), + DeviceCategory.FSKG: FanEntityDescription(key=""), + DeviceCategory.KJ: FanEntityDescription(key=""), + DeviceCategory.KS: FanEntityDescription(key=""), } _TUYA_TO_HA_DIRECTION_MAPPINGS = { @@ -57,10 +53,10 @@ async def async_setup_entry( entities: list[TuyaFanEntity] = [] for device_id in device_ids: device = manager.device_map[device_id] - if device.category in TUYA_SUPPORT_TYPE and ( + if (description := FANS.get(device.category)) and ( definition := get_default_definition(device) ): - entities.append(TuyaFanEntity(device, manager, definition)) + entities.append(TuyaFanEntity(device, manager, description, definition)) async_add_entities(entities) async_discover_device([*manager.device_map]) @@ -79,10 +75,11 @@ class TuyaFanEntity(TuyaEntity, FanEntity): self, device: CustomerDevice, device_manager: Manager, - definition: TuyaFanDefinition, + description: FanEntityDescription, + definition: FanDefinition, ) -> None: """Init Tuya Fan Device.""" - super().__init__(device, device_manager) + super().__init__(device, device_manager, description) self._direction_wrapper = definition.direction_wrapper self._mode_wrapper = definition.mode_wrapper self._oscillate_wrapper = definition.oscillate_wrapper 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..120e65882b9 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) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 15d9402e2e9..2b36d6ea0db 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.19", "tuya-device-sharing-sdk==0.2.8" ] } diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 078865f5a24..2c058d2d0ba 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,7 +17,6 @@ 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, @@ -28,6 +25,7 @@ from .const import ( DeviceCategory, DPCode, ) +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = { @@ -487,7 +485,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) 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..b1a2a48e7fb 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,8 +12,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 Enum data types in the @@ -390,7 +388,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 c950064e7f2..45eb83063a9 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1,11 +1,9 @@ """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 @@ -35,6 +33,7 @@ from homeassistant.const import ( EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, + UnitOfEnergy, UnitOfPower, UnitOfTime, ) @@ -43,7 +42,6 @@ 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, @@ -52,6 +50,7 @@ from .const import ( DeviceCategory, DPCode, ) +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity CURRENT_WRAPPER = (ElectricityCurrentRawWrapper, ElectricityCurrentJsonWrapper) @@ -378,6 +377,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, @@ -656,6 +656,8 @@ 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, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, ), TuyaSensorEntityDescription( key=DPCode.PRO_ADD_ELE, @@ -1677,7 +1679,7 @@ 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) 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..9747f16c95d 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, ...]] = { @@ -93,7 +91,7 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity): 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..b843f06dd07 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -1099,6 +1099,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..a23431fc2cc 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 @@ -945,7 +943,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..6fcb7e67600 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -1,35 +1,14 @@ """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.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceInfo from .const import DOMAIN, DPCode -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 - - if not isinstance(dpcodes, tuple): - dpcodes = (dpcodes,) - - for dpcode in dpcodes: - if ( - dpcode in device.function - or dpcode in device.status - or dpcode in device.status_range - ): - return dpcode - - return None - - class ActionDPCodeNotFoundError(ServiceValidationError): """Custom exception for action DP code not found errors.""" @@ -54,3 +33,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 f6e7b79bcdd..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 ( @@ -16,6 +14,7 @@ from tuya_sharing import CustomerDevice, Manager from homeassistant.components.vacuum import ( StateVacuumEntity, + StateVacuumEntityDescription, VacuumActivity, VacuumEntityFeature, ) @@ -23,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 = { @@ -36,6 +35,10 @@ _TUYA_TO_HA_ACTIVITY_MAPPINGS = { TuyaVacuumActivity.ERROR: VacuumActivity.ERROR, } +VACUUMS: dict[DeviceCategory, StateVacuumEntityDescription] = { + DeviceCategory.SD: StateVacuumEntityDescription(key=""), +} + async def async_setup_entry( hass: HomeAssistant, @@ -51,9 +54,11 @@ async def async_setup_entry( entities: list[TuyaVacuumEntity] = [] for device_id in device_ids: device = manager.device_map[device_id] - if device.category == DeviceCategory.SD: + if description := VACUUMS.get(device.category): entities.append( - TuyaVacuumEntity(device, manager, get_default_definition(device)) + TuyaVacuumEntity( + device, manager, description, get_default_definition(device) + ) ) async_add_entities(entities) @@ -73,10 +78,11 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): self, device: CustomerDevice, device_manager: Manager, - definition: TuyaVacuumDefinition, + description: StateVacuumEntityDescription, + definition: VacuumDefinition, ) -> None: """Init Tuya vacuum.""" - super().__init__(device, device_manager) + super().__init__(device, device_manager, description) self._action_wrapper = definition.action_wrapper self._activity_wrapper = definition.activity_wrapper self._fan_speed_wrapper = definition.fan_speed_wrapper 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..71dfe7267ff 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 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/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/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..41331166cbf 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: 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..a485816a3d2 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 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..71b0fd22c10 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 @@ -35,11 +32,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback 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 ( @@ -66,12 +59,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 +131,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 +166,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 +205,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() 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..caf6edde811 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 @@ -35,6 +33,7 @@ from .entity import UnifiEntity, UnifiEntityDescription, async_device_available_ from .hub import UnifiHub LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 CLIENT_TRACKER = "client" DEVICE_TRACKER = "device" 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..b7d93957c47 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,26 @@ 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..d37acbfdab8 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).""" @@ -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 f9954e9743e..86d97ad7647 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==88"], - "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==90"] } 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..57888cd2c08 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: @@ -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,21 @@ 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", + translation_key="client_uptime", device_class=SensorDeviceClass.TIMESTAMP, 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 +553,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 +560,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 +568,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 +586,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 +593,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 +602,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 +609,12 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Devices, Device]( key="Device uptime", + translation_key="device_uptime", device_class=SensorDeviceClass.TIMESTAMP, 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 +628,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 +640,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 +654,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 +668,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 +682,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}", 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..80c70ef736a 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,56 @@ "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" + }, + "client_uptime": { + "name": "Uptime" + }, + "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 +96,73 @@ "upgrading": "Upgrading" } }, + "device_sub_temperature": { + "name": "{name} temperature" + }, + "device_uplink_mac": { + "name": "Uplink MAC" + }, + "device_uptime": { + "name": "Uptime" + }, + "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": { @@ -73,7 +174,12 @@ "data": { "block_client": "Network access controlled clients", "dpi_restrictions": "Allow control of DPI restriction groups", - "poe_clients": "Allow POE control of clients" + "poe_clients": "Allow PoE control of clients" + }, + "data_description": { + "block_client": "Select clients whose network access you want to control via switches.", + "dpi_restrictions": "Enable switches to control DPI restriction groups.", + "poe_clients": "Enable switches to control PoE power for clients." }, "description": "Configure client controls\n\nCreate switches for serial numbers you want to control network access for.", "title": "UniFi Network options 2/3" @@ -82,6 +188,9 @@ "data": { "client_source": "Create entities from network clients" }, + "data_description": { + "client_source": "Select which network clients to create entities from." + }, "description": "Select sources to create entities from", "title": "UniFi Network Entity Sources" }, @@ -94,6 +203,14 @@ "track_devices": "Track network devices (Ubiquiti devices)", "track_wired_clients": "Include wired network clients" }, + "data_description": { + "detection_time": "Number of seconds since last seen before a client is considered away.", + "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_clients": "Create device tracker entities for network clients.", + "track_devices": "Create device tracker entities for Ubiquiti network devices.", + "track_wired_clients": "Include wired clients in device tracking." + }, "description": "Configure device tracking", "title": "UniFi Network options 1/3" }, @@ -103,6 +220,11 @@ "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": "[%key:component::unifi::options::step::client_control::data_description::block_client%]", + "track_clients": "[%key:component::unifi::options::step::device_tracker::data_description::track_clients%]", + "track_devices": "[%key:component::unifi::options::step::device_tracker::data_description::track_devices%]" + }, "description": "Configure UniFi Network integration" }, "statistics_sensors": { @@ -110,6 +232,10 @@ "allow_bandwidth_sensors": "Bandwidth usage sensors for network clients", "allow_uptime_sensors": "Uptime sensors for network clients" }, + "data_description": { + "allow_bandwidth_sensors": "Create bandwidth usage sensors for network clients.", + "allow_uptime_sensors": "Create uptime sensors for network clients." + }, "description": "Configure statistics sensors", "title": "UniFi Network options 3/3" } diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index b39020204a5..1ce34cacd44 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,6 +48,7 @@ from homeassistant.components.switch import ( ) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -67,6 +66,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) @@ -258,8 +259,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 +273,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 +299,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 +311,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 +323,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 +344,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, @@ -442,7 +436,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 +450,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 b73b99fbce8..9109f48ec2b 100644 --- a/homeassistant/components/unifi_access/__init__.py +++ b/homeassistant/components/unifi_access/__init__.py @@ -1,15 +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, @@ -22,15 +27,25 @@ 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]) + ssl_context = ( + None if entry.data[CONF_VERIFY_SSL] else create_no_verify_ssl_context() + ) client = UnifiAccessApiClient( host=entry.data[CONF_HOST], api_token=entry.data[CONF_API_TOKEN], session=session, verify_ssl=entry.data[CONF_VERIFY_SSL], + ssl_context=ssl_context, ) try: diff --git a/homeassistant/components/unifi_access/binary_sensor.py b/homeassistant/components/unifi_access/binary_sensor.py index a59dc4d2b1c..05ea518a19a 100644 --- a/homeassistant/components/unifi_access/binary_sensor.py +++ b/homeassistant/components/unifi_access/binary_sensor.py @@ -1,14 +1,12 @@ """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 ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator @@ -24,10 +22,23 @@ async def async_setup_entry( ) -> None: """Set up UniFi Access binary sensor entities.""" coordinator = entry.runtime_data - async_add_entities( - UnifiAccessDoorPositionBinarySensor(coordinator, door) - for door in coordinator.data.doors.values() - ) + added_doors: set[str] = set() + + @callback + def _async_add_new_doors() -> None: + new_door_ids = sorted(set(coordinator.data.doors) - added_doors) + if not new_door_ids: + return + async_add_entities( + UnifiAccessDoorPositionBinarySensor( + coordinator, coordinator.data.doors[door_id] + ) + for door_id in new_door_ids + ) + added_doors.update(new_door_ids) + + _async_add_new_doors() + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_doors)) class UnifiAccessDoorPositionBinarySensor(UnifiAccessEntity, BinarySensorEntity): diff --git a/homeassistant/components/unifi_access/button.py b/homeassistant/components/unifi_access/button.py index d1c795006cf..67e4d0004c3 100644 --- a/homeassistant/components/unifi_access/button.py +++ b/homeassistant/components/unifi_access/button.py @@ -1,11 +1,9 @@ """Button platform for the UniFi Access integration.""" -from __future__ import annotations - from unifi_access_api import Door, UnifiAccessError from homeassistant.components.button import ButtonEntity -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,10 +21,21 @@ async def async_setup_entry( ) -> None: """Set up UniFi Access button entities.""" coordinator = entry.runtime_data - async_add_entities( - UnifiAccessUnlockButton(coordinator, door) - for door in coordinator.data.doors.values() - ) + added_doors: set[str] = set() + + @callback + def _async_add_new_doors() -> None: + new_door_ids = sorted(set(coordinator.data.doors) - added_doors) + if not new_door_ids: + return + async_add_entities( + UnifiAccessUnlockButton(coordinator, coordinator.data.doors[door_id]) + for door_id in new_door_ids + ) + added_doors.update(new_door_ids) + + _async_add_new_doors() + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_doors)) class UnifiAccessUnlockButton(UnifiAccessEntity, ButtonEntity): diff --git a/homeassistant/components/unifi_access/config_flow.py b/homeassistant/components/unifi_access/config_flow.py index 87acc7a84ed..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,11 @@ 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 @@ -24,22 +24,35 @@ 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] = {} session = async_get_clientsession( self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] ) + ssl_context = ( + None if user_input[CONF_VERIFY_SSL] else create_no_verify_ssl_context() + ) client = UnifiAccessApiClient( host=user_input[CONF_HOST], api_token=user_input[CONF_API_TOKEN], session=session, verify_ssl=user_input[CONF_VERIFY_SSL], + ssl_context=ssl_context, ) 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: @@ -108,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 af29b9e2ae4..52616998367 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,12 +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, @@ -36,13 +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] @@ -91,6 +100,7 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): ) self.client = client self._event_listeners: list[Callable[[DoorEvent], None]] = [] + self._device_to_door: dict[str, str] = {} @callback def async_subscribe_door_events( @@ -105,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 @@ -138,8 +174,12 @@ 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, } self.client.start_websocket( @@ -194,6 +234,16 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): supports_lock_rules = bool(door_lock_rules) or bool(unconfirmed_lock_rule_doors) + current_ids = {door.id for door in doors} | {self.config_entry.entry_id} + self._remove_stale_devices(current_ids) + + current_door_ids = {door.id for door in doors} + self._device_to_door = { + dev_id: door_id + for dev_id, door_id in self._device_to_door.items() + if door_id in current_door_ids + } + return UnifiAccessData( doors={door.id: door for door in doors}, emergency=emergency, @@ -221,6 +271,23 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): except ApiNotFoundError: return None + @callback + def _remove_stale_devices(self, current_ids: set[str]) -> None: + """Remove devices for doors that no longer exist on the hub.""" + device_registry = dr.async_get(self.hass) + for device in dr.async_entries_for_config_entry( + device_registry, self.config_entry.entry_id + ): + if any( + identifier[0] == DOMAIN and identifier[1] in current_ids + for identifier in device.identifiers + ): + continue + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + def _on_ws_connect(self) -> None: """Handle WebSocket connection established.""" _LOGGER.debug("WebSocket connected to UniFi Access") @@ -247,9 +314,20 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): async def _handle_v2_location_update(self, msg: WebsocketMessage) -> None: """Handle V2 location update messages.""" update = cast(V2LocationUpdate, msg) - self._process_door_update( - update.data.id, update.data.state, update.data.thumbnail - ) + door_id = update.data.id + + stale_device_ids = [ + device_id + for device_id, mapped_door_id in self._device_to_door.items() + if mapped_door_id == door_id + ] + for device_id in stale_device_ids: + del self._device_to_door[device_id] + + for device_id in update.data.device_ids: + self._device_to_door[device_id] = door_id + + self._process_door_update(door_id, update.data.state, update.data.thumbnail) def _process_door_update( self, @@ -272,12 +350,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 @@ -355,6 +434,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) @@ -371,10 +495,40 @@ 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) + async def _handle_logs_add(self, msg: WebsocketMessage) -> None: + """Handle access log events (entry/exit via access.logs.add).""" + log = cast(LogAdd, msg) + source = log.data.source + device_target = source.device_config + 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 + event_type = ( + "access_granted" if source.event.result == "ACCESS" else "access_denied" + ) + attrs: dict[str, Any] = {} + if source.actor.display_name: + attrs["actor"] = source.actor.display_name + if source.authentication.credential_provider: + 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: """Return the current lock rule status for a door.""" return self.data.door_lock_rules.get(door_id) 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 b13bdce869e..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", ) @@ -55,11 +54,24 @@ async def async_setup_entry( ) -> None: """Set up UniFi Access event entities.""" coordinator = entry.runtime_data - async_add_entities( - UnifiAccessEventEntity(coordinator, door, description) - for door in coordinator.data.doors.values() - for description in EVENT_DESCRIPTIONS - ) + added_doors: set[str] = set() + + @callback + def _async_add_new_doors() -> None: + new_door_ids = sorted(set(coordinator.data.doors) - added_doors) + if not new_door_ids: + return + async_add_entities( + UnifiAccessEventEntity( + coordinator, coordinator.data.doors[door_id], description + ) + for door_id in new_door_ids + for description in EVENT_DESCRIPTIONS + ) + added_doors.update(new_door_ids) + + _async_add_new_doors() + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_doors)) class UnifiAccessEventEntity(UnifiAccessEntity, EventEntity): 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 ccb45ede0c0..e7e90560718 100644 --- a/homeassistant/components/unifi_access/image.py +++ b/homeassistant/components/unifi_access/image.py @@ -1,19 +1,20 @@ """Image platform for the UniFi Access integration.""" -from __future__ import annotations - from datetime import UTC, datetime +import logging -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 +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator from .entity import UnifiAccessEntity +_LOGGER = logging.getLogger(__name__) + PARALLEL_UPDATES = 0 @@ -24,10 +25,26 @@ async def async_setup_entry( ) -> None: """Set up image entities for UniFi Access doors.""" coordinator = entry.runtime_data - async_add_entities( - UnifiAccessDoorImageEntity(coordinator, hass, entry.data[CONF_VERIFY_SSL], door) - for door in coordinator.data.doors.values() - ) + added_doors: set[str] = set() + + @callback + def _async_add_new_doors() -> None: + new_door_ids = sorted(set(coordinator.data.door_thumbnails) - added_doors) + if not new_door_ids: + return + async_add_entities( + UnifiAccessDoorImageEntity( + coordinator, + hass, + entry.data[CONF_VERIFY_SSL], + coordinator.data.doors[door_id], + ) + for door_id in new_door_ids + ) + added_doors.update(new_door_ids) + + _async_add_new_doors() + entry.async_on_unload(coordinator.async_add_listener(_async_add_new_doors)) class UnifiAccessDoorImageEntity(UnifiAccessEntity, ImageEntity): @@ -56,7 +73,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) + except UnifiAccessError as err: + _LOGGER.warning( + "Failed to fetch thumbnail for door %s: %s", + self._door_id, + 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 42a8ac4cfca..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,25 +38,31 @@ 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 - entity-category: todo - entity-device-class: todo - entity-disabled-by-default: todo - entity-translations: 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 + entity-disabled-by-default: + status: exempt + comment: All entities provide essential data and should be enabled by default. + entity-translations: done exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: todo - stale-devices: todo + repair-issues: + status: exempt + comment: Integration raises ConfigEntryAuthFailed and relies on Home Assistant core to surface reauth/repair issues, no custom repairs are defined. + stale-devices: done # Platinum async-dependency: 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..d20fb8e81cd 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%]" @@ -124,11 +136,48 @@ "emergency_failed": { "message": "Failed to set emergency status." }, + "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." } + }, + "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..fa4d34b9fbc 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,23 @@ 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(err) 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 +138,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 +156,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..f6f9e187023 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, @@ -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: 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..226875e20a5 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.""" @@ -336,6 +383,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 +418,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 +462,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..8115ddc124d 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 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 b94b2250797..215381a8fe1 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.2.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.4.1"] } 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..fe09a0f47dc 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 diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index 2c631489217..7f9596416a2 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 diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index ab21d0a8670..c3503000ee5 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 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..54c10e53de9 100644 --- a/homeassistant/components/unifiprotect/repairs.py +++ b/homeassistant/components/unifiprotect/repairs.py @@ -1,7 +1,5 @@ """unifiprotect.repairs.""" -from __future__ import annotations - from typing import cast from uiprotect import ProtectApiClient diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 24a2791c88b..98163a92d4b 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, @@ -26,18 +25,22 @@ from uiprotect.data import ( 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, @@ -379,6 +382,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 +511,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..a7d9afbc9f9 --- /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..faea04b49e5 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" }, @@ -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,9 @@ "device_not_found": { "message": "No device found for device id: {device_id}" }, + "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,9 +703,18 @@ "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" } diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index a5b399ef8c4..4abe0a0d615 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -1,26 +1,31 @@ """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, + 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, @@ -421,6 +426,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 +573,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..b5e10f9b4fd 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 @@ -107,7 +105,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, diff --git a/homeassistant/components/upb/__init__.py b/homeassistant/components/upb/__init__.py index ebfc8eaeece..464fb9cf57b 100644 --- a/homeassistant/components/upb/__init__.py +++ b/homeassistant/components/upb/__init__.py @@ -8,19 +8,15 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_COMMAND, 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] @@ -29,8 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b 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,15 +52,13 @@ 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 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/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/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..e0e43b43b0c 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=hass-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 2d9f13f02ad..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 @@ -531,7 +529,13 @@ async def websocket_release_notes( "Entity does not support release notes", ) return - + if entity.available is False: + connection.send_error( + msg["id"], + websocket_api.ERR_HOME_ASSISTANT_ERROR, + "Entity is not available", + ) + return connection.send_result( msg["id"], await entity.async_release_notes(), 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 c0851796efe..0b8484d0baf 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -1,7 +1,35 @@ { "common": { - "trigger_behavior_description": "The behavior of the targeted updates to become available.", - "trigger_behavior_name": "Behavior" + "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": { @@ -59,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,8 +117,10 @@ "description": "Triggers after one or more updates become available.", "fields": { "behavior": { - "description": "[%key:component::update::common::trigger_behavior_description%]", "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..15ab518ef96 100644 --- a/homeassistant/components/update/triggers.yaml +++ b/homeassistant/components/update/triggers.yaml @@ -7,11 +7,12 @@ required: true default: any 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..e73a2406089 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 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..b650c0a050e 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 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..8a95bcea4b5 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 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..d2c6a6a00d9 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 diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index d76a727cba1..fc71f3a2fa0 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 ( 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..c9da4f46f02 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 diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py index 16e49f6e408..0a25d53ef4e 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 ( 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..3d94a920ea6 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 diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index c7c2ea469a8..08690dc8aab 100644 --- a/homeassistant/components/uptimerobot/manifest.json +++ b/homeassistant/components/uptimerobot/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pyuptimerobot"], "quality_scale": "gold", - "requirements": ["pyuptimerobot==24.0.1"] + "requirements": ["pyuptimerobot==25.0.0"] } diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index 37cfcc1266d..234aede5400 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 ( @@ -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/switch.py b/homeassistant/components/uptimerobot/switch.py index dc519555859..0520da93505 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 STATUS_UP 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 @@ -65,26 +58,14 @@ class UptimeRobotSwitch(UptimeRobotEntity, SwitchEntity): """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() - + @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..f0ae42ac9ac 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_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 ec726bba460..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,35 +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 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) @@ -91,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 @@ -159,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 @@ -188,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() @@ -303,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.""" @@ -354,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"]: @@ -425,11 +489,16 @@ class USBDiscovery: async def _async_scan_serial(self) -> None: """Scan serial ports.""" - _LOGGER.debug("Executing comports scan") + _LOGGER.debug("Executing USB serial device scan") async with self._scan_lock: - await self._async_process_ports( - await self.hass.async_add_executor_job(scan_serial_ports) - ) + # Only consider USB-serial ports for discovery + usb_ports = [ + p + for p in await self.async_scan_serial_ports() + if isinstance(p, USBDevice) + ] + + await self._async_process_ports(usb_ports) if self.initial_scan_done: return @@ -468,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..222df3e2c06 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.7.1"] } diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py index 11eccd9cd9b..840978e5ea4 100644 --- a/homeassistant/components/usb/models.py +++ b/homeassistant/components/usb/models.py @@ -1,17 +1,26 @@ """Models helper class for the usb integration.""" -from __future__ import annotations - from dataclasses import dataclass @dataclass(slots=True, frozen=True, kw_only=True) -class USBDevice: - """A usb device.""" +class SerialDevice: + """A serial device.""" device: str - vid: str - pid: str 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) +class USBDevice(SerialDevice): + """A usb device.""" + + 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 23248e19f58..a4c1c30a8c0 100644 --- a/homeassistant/components/usb/utils.py +++ b/homeassistant/components/usb/utils.py @@ -1,57 +1,57 @@ """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 -from .models import USBDevice +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 + return USBDevice( device=port.device, vid=f"{hex(port.vid)[2:]:0>4}".upper(), pid=f"{hex(port.pid)[2:]:0>4}".upper(), serial_number=port.serial_number, manufacturer=port.manufacturer, - description=port.description, + description=port.product, + bcd_device=port.bcd_device, + interface_description=port.interface_description, + interface_num=port.interface_num, ) -def scan_serial_ports() -> Sequence[USBDevice]: - """Scan serial ports for USB devices.""" +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.product, + interface_description=port.interface_description, + interface_num=port.interface_num, + ) - # 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 = [] +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) - for port in comports(): - if port.vid is not None or port.pid is not None: - usb_device = usb_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 - usb_device = dataclasses.replace(usb_device, device=device_path) - - serial_ports.append(usb_device) - - return serial_ports +def scan_serial_ports() -> Sequence[USBDevice | SerialDevice]: + """Scan serial ports and return USB and other serial devices.""" + return [usb_serial_device_from_port(port) for port in list_serial_ports()] def usb_device_from_path(device_path: str) -> USBDevice | None: @@ -60,6 +60,10 @@ def usb_device_from_path(device_path: str) -> USBDevice | None: device_path_real = os.path.realpath(device_path) for device in scan_serial_ports(): + # Skip non-USB serial devices + if not isinstance(device, USBDevice): + continue + if os.path.realpath(device.device) == device_path_real: return device 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/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..83a7ac5348f 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -1,7 +1,5 @@ """Support for tariff selection.""" -from __future__ import annotations - import logging from homeassistant.components.select import SelectEntity diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index f7e6f6e3008..53c4c4b7bde 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -1,12 +1,11 @@ """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 from decimal import Decimal, DecimalException, InvalidOperation import logging +import math from typing import Any, Self from cronsim import CronSim @@ -52,7 +51,6 @@ from homeassistant.helpers.event import ( async_track_state_change_event, ) from homeassistant.helpers.start import async_at_started -from homeassistant.helpers.template import is_number from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify from homeassistant.util.enum import try_parse_enum @@ -113,8 +111,11 @@ COLLECTING = "collecting" def validate_is_number(value): """Validate value is a number.""" - if is_number(value): - return value + try: + if math.isfinite(float(value)): + return value + except ValueError, TypeError: + pass raise vol.Invalid("Value is not a number") 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..118460a0ed6 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( @@ -424,45 +424,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 ebd0febdbed..95267e6a1e2 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted vacuum cleaners.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted vacuum cleaners to trigger on.", - "trigger_behavior_name": "Behavior" + "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_cleaning": { "description": "Tests if one or more vacuum cleaners are cleaning.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is cleaning" @@ -20,8 +22,10 @@ "description": "Tests if one or more vacuum cleaners are docked.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is docked" @@ -30,8 +34,10 @@ "description": "Tests if one or more vacuum cleaners are encountering an error.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is encountering an error" @@ -40,8 +46,10 @@ "description": "Tests if one or more vacuum cleaners are paused.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is paused" @@ -50,8 +58,10 @@ "description": "Tests if one or more vacuum cleaners are returning to the dock.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::condition_behavior_description%]", "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is returning" @@ -92,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.", @@ -197,8 +194,10 @@ "description": "Triggers after one or more vacuums have returned to dock.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum returned to dock" @@ -207,8 +206,10 @@ "description": "Triggers after one or more vacuums encounter an error.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum encountered an error" @@ -217,8 +218,10 @@ "description": "Triggers after one or more vacuums pause cleaning.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum cleaner paused cleaning" @@ -227,8 +230,10 @@ "description": "Triggers after one or more vacuums start cleaning.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum cleaner started cleaning" @@ -237,8 +242,10 @@ "description": "Triggers after one or more vacuums start returning to dock.", "fields": { "behavior": { - "description": "[%key:component::vacuum::common::trigger_behavior_description%]", "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..95c3b4da916 100644 --- a/homeassistant/components/vacuum/triggers.yaml +++ b/homeassistant/components/vacuum/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any 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/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..223761b9035 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) @@ -188,7 +176,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 +194,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 +215,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..b585c5c3666 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 @@ -279,12 +276,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..6bedbe5ab01 --- /dev/null +++ b/homeassistant/components/vallox/services.py @@ -0,0 +1,136 @@ +"""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 .const import DOMAIN, I18N_KEY_TO_VALLOX_PROFILE +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=65535) + ), + } +) + + +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: + _LOGGER.error("Error setting fan speed for %s profile: %s", profile.name, 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: + _LOGGER.error( + "Error setting profile %s for duration %s: %s", + profile_key, + duration, + 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/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..febb416a077 100644 --- a/homeassistant/components/valve/__init__.py +++ b/homeassistant/components/valve/__init__.py @@ -1,7 +1,5 @@ """Support for Valve devices.""" -from __future__ import annotations - from dataclasses import dataclass from datetime import timedelta from enum import IntFlag, StrEnum 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/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 cd01e3142cf..f433e87b02b 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -1,15 +1,19 @@ { "common": { - "trigger_behavior_description": "The behavior of the targeted valves to trigger on.", - "trigger_behavior_name": "Behavior" + "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_closed": { "description": "Tests if one or more valves are closed.", "fields": { "behavior": { - "description": "Whether the condition should pass when any or all targeted entities match.", - "name": "Behavior" + "name": "[%key:component::valve::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::valve::common::condition_for_name%]" } }, "name": "Valve is closed" @@ -18,8 +22,10 @@ "description": "Tests if one or more valves are open.", "fields": { "behavior": { - "description": "Whether the condition should pass when any or all targeted entities match.", - "name": "Behavior" + "name": "[%key:component::valve::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::valve::common::condition_for_name%]" } }, "name": "Valve is open" @@ -48,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.", @@ -97,8 +88,10 @@ "description": "Triggers after one or more valves close.", "fields": { "behavior": { - "description": "[%key:component::valve::common::trigger_behavior_description%]", "name": "[%key:component::valve::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::valve::common::trigger_for_name%]" } }, "name": "Valve closed" @@ -107,8 +100,10 @@ "description": "Triggers after one or more valves open.", "fields": { "behavior": { - "description": "[%key:component::valve::common::trigger_behavior_description%]", "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..fa880594ba6 100644 --- a/homeassistant/components/valve/triggers.yaml +++ b/homeassistant/components/valve/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any 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..5aaefab1874 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 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..0550837aed1 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -57,7 +57,7 @@ rules: entity-translations: todo exception-translations: done 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..11903788614 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 diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 3d672a574d6..863971ac1be 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,29 +1,17 @@ """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 @@ -32,47 +20,6 @@ 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] diff --git a/homeassistant/components/velux/binary_sensor.py b/homeassistant/components/velux/binary_sensor.py index 1b87633c9cd..77c7df35a09 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 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/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..3da1d1038d1 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -56,7 +56,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..820442830e7 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.33"] } 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..6e029c51fbc 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: diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index 8e4b7e35f43..e07ddbb75d6 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): @@ -90,7 +86,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 +105,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..5fdd7593d1e 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.""" 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..b643834dda0 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 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..cb61e67e5d1 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,22 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelState, CodeFormat, ) -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.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( 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..2d1fefa28ab 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() 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/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/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/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..fdf2d253291 100644 --- a/homeassistant/components/version/__init__.py +++ b/homeassistant/components/version/__init__.py @@ -1,7 +1,5 @@ """The Version integration.""" -from __future__ import annotations - import logging from pyhaversion import HaVersion diff --git a/homeassistant/components/version/binary_sensor.py b/homeassistant/components/version/binary_sensor.py index 900daa7aba1..a53f8c0b5f4 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 ( diff --git a/homeassistant/components/version/config_flow.py b/homeassistant/components/version/config_flow.py index 17cd07aac6f..d9af080ca76 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 diff --git a/homeassistant/components/version/const.py b/homeassistant/components/version/const.py index c0a5062bedb..8180ee8e999 100644 --- a/homeassistant/components/version/const.py +++ b/homeassistant/components/version/const.py @@ -1,7 +1,5 @@ """Constants for the Version integration.""" -from __future__ import annotations - from datetime import timedelta from logging import Logger, getLogger from typing import Any, Final 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..117dad3c348 100644 --- a/homeassistant/components/version/sensor.py +++ b/homeassistant/components/version/sensor.py @@ -1,7 +1,5 @@ """Sensor that can display the current Home Assistant versions.""" -from __future__ import annotations - from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription 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/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/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..839d26802f3 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 diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 8b4a83855e0..566dfa16b3e 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -1,7 +1,5 @@ """The ViCare integration.""" -from __future__ import annotations - from contextlib import suppress import logging import os 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..58eab88df92 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 diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index 73ce51a2b8e..777de83aa5e 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -1,7 +1,5 @@ """Config flow for ViCare integration.""" -from __future__ import annotations - from collections.abc import Mapping import logging from typing import Any 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..0f8258a8209 100644 --- a/homeassistant/components/vicare/diagnostics.py +++ b/homeassistant/components/vicare/diagnostics.py @@ -1,7 +1,5 @@ """Diagnostics support for ViCare.""" -from __future__ import annotations - import json from typing import Any @@ -23,7 +21,7 @@ async def async_get_config_entry_diagnostics( """Dump devices.""" return [ json.loads(device.dump_secure()) - for device in entry.runtime_data.client.devices + for device in entry.runtime_data.client.all_devices ] return { 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..10cc32d2226 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -1,7 +1,7 @@ { "domain": "vicare", "name": "Viessmann ViCare", - "codeowners": ["@CFenner"], + "codeowners": ["@CFenner", "@lackas"], "config_flow": true, "dhcp": [ { @@ -12,5 +12,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..f3b009c2dcd 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 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 bde04783a4f..873ffde0130 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 @@ -54,7 +52,7 @@ class VictronBLEConfigFlow(ConfigFlow, domain=DOMAIN): self._discovered_devices_info[discovery_info.address] = discovery_info self._discovered_devices[discovery_info.address] = discovery_info.name - self.context["title_placeholders"] = {"title": discovery_info.name} + self.context["title_placeholders"] = {"name": discovery_info.name} return await self.async_step_access_token() diff --git a/homeassistant/components/victron_ble/manifest.json b/homeassistant/components/victron_ble/manifest.json index 85455f039e9..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.2"] + "requirements": ["victron-ble-ha-parser==0.7.0"] } diff --git a/homeassistant/components/victron_ble/quality_scale.yaml b/homeassistant/components/victron_ble/quality_scale.yaml index 5eedb4ea163..13853d1d1be 100644 --- a/homeassistant/components/victron_ble/quality_scale.yaml +++ b/homeassistant/components/victron_ble/quality_scale.yaml @@ -42,10 +42,8 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: todo - parallel-updates: - status: done - reauthentication-flow: - status: todo + parallel-updates: done + reauthentication-flow: todo test-coverage: done # Gold devices: done diff --git a/homeassistant/components/victron_ble/sensor.py b/homeassistant/components/victron_ble/sensor.py index 18a112ab700..f547cef8a3f 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, @@ -538,4 +532,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_ble/strings.json b/homeassistant/components/victron_ble/strings.json index c599e61a83a..5478a595cd4 100644 --- a/homeassistant/components/victron_ble/strings.json +++ b/homeassistant/components/victron_ble/strings.json @@ -18,7 +18,7 @@ "invalid_access_token": "Invalid encryption key for instant readout", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" }, - "flow_title": "{title}", + "flow_title": "{name}", "step": { "access_token": { "data": { @@ -145,7 +145,7 @@ "input_current": "Input overcurrent", "input_power": "Input overpower", "input_shutdown_current": "Input shutdown (current flow during off mode)", - "input_shutdown_failure": "PV input failed to shutdown", + "input_shutdown_failure": "PV input shutdown failed", "input_shutdown_voltage": "Input shutdown (battery overvoltage)", "input_voltage": "Input overvoltage", "internal_dc_voltage": "Internal DC voltage error", diff --git a/homeassistant/components/victron_gx/__init__.py b/homeassistant/components/victron_gx/__init__.py new file mode 100644 index 00000000000..a8e309e07ba --- /dev/null +++ b/homeassistant/components/victron_gx/__init__.py @@ -0,0 +1,76 @@ +"""The victron_gx integration.""" + +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.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + Platform.TIME, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: VictronGxConfigEntry) -> bool: + """Set up victron_gx from a config entry.""" + _LOGGER.debug("async_setup_entry called for entry: %s", entry.entry_id) + + hub = Hub(hass, entry) + entry.runtime_data = hub + + # All platforms should be set up before starting the hub + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + try: + await hub.start() + except Exception as err: + _LOGGER.error( + "Error starting hub for entry %s: %s", + entry.entry_id, + err, + exc_info=err, + ) + await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + hub.unregister_all_new_metric_callbacks() + raise + + async def _async_stop(_: Event) -> None: + await hub.stop() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) + ) + + _LOGGER.debug("async_setup_entry completed for entry: %s", entry.entry_id) + return True + + +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 = entry.runtime_data + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + 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/config_flow.py b/homeassistant/components/victron_gx/config_flow.py new file mode 100644 index 00000000000..6a1622ff8bf --- /dev/null +++ b/homeassistant/components/victron_gx/config_flow.py @@ -0,0 +1,386 @@ +"""Config flow for the Victron GX integration.""" + +from collections.abc import Mapping +import logging +from typing import Any +from urllib.parse import urlparse + +from victron_mqtt import AuthenticationError, CannotConnectError, Hub as VictronVenusHub +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +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 + +DEFAULT_HOST = "venus.local" +DEFAULT_PORT = 1883 + +_LOGGER = logging.getLogger(__name__) + +TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} + +ENTRY_TITLE_FORMAT = "Victron OS {installation_id} ({host}:{port})" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): selector.TextSelector(), + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_USERNAME): selector.TextSelector(), + vol.Optional(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + vol.Required(CONF_SSL, default=False): selector.BooleanSelector(), + } +) + +STEP_SSDP_AUTH_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, default=False): selector.BooleanSelector(), + } +) + +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. + + Data has the keys from SSDP values as well as user input. + + Returns the installation id upon success. + """ + _LOGGER.debug("Validating input: %s", async_redact_data(data, TO_REDACT)) + hub: VictronVenusHub | None = None + try: + hub = VictronVenusHub( + host=data[CONF_HOST], + port=int(data[CONF_PORT]), + username=data.get(CONF_USERNAME) or None, + password=data.get(CONF_PASSWORD) or None, + use_ssl=data.get(CONF_SSL, False), + installation_id=data.get(CONF_INSTALLATION_ID) or None, + serial=data.get(CONF_SERIAL) or None, + ) + + await hub.connect() + if hub.installation_id is None: + raise CannotConnectError("Victron hub did not provide an installation_id") + + return hub.installation_id + finally: + if hub is not None: + try: + await hub.disconnect() + except Exception: # noqa: BLE001 + _LOGGER.debug("Ignoring disconnect error during config validation") + + +class VictronGXConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the config flow for Victron GX devices.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self.hostname: str | None = None + self.serial: str | None = None + self.installation_id: str | None = None + self.friendly_name: str | None = None + self.model_name: str | 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: + _LOGGER.debug( + "User input received: %s", + async_redact_data(user_input, TO_REDACT), + ) + data = {**user_input, CONF_SERIAL: self.serial, CONF_MODEL: self.model_name} + + try: + installation_id = await validate_input(data) + _LOGGER.debug( + "Successfully connected to Victron device: %s", installation_id + ) + except AuthenticationError: + _LOGGER.debug( + "Authentication failed during initial setup", exc_info=True + ) + errors["base"] = "invalid_auth" + except CannotConnectError: + _LOGGER.debug("Cannot connect to Victron device", exc_info=True) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error connecting to Victron device") + errors["base"] = "unknown" + else: + data[CONF_INSTALLATION_ID] = installation_id + unique_id = installation_id + await self.async_set_unique_id(unique_id) + + self._abort_if_unique_id_configured() + title = ENTRY_TITLE_FORMAT.format( + installation_id=installation_id, + host=data[CONF_HOST], + port=data[CONF_PORT], + ) + return self.async_create_entry(title=title, data=data) + + _LOGGER.debug("Showing form with errors: %s", errors) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, + ) + + async def async_step_ssdp( + self, discovery_info: SsdpServiceInfo + ) -> ConfigFlowResult: + """Handle SSDP discovery.""" + self.hostname = str(urlparse(discovery_info.ssdp_location).hostname) + self.serial = discovery_info.upnp["serialNumber"] + self.installation_id = discovery_info.upnp["X_VrmPortalId"] + self.model_name = discovery_info.upnp["modelName"] + self.friendly_name = discovery_info.upnp["friendlyName"] + + await self.async_set_unique_id(self.installation_id) + self._abort_if_unique_id_configured() + + self.context["title_placeholders"] = { + "name": self.friendly_name or self.hostname + } + + # Verify connectivity before showing the confirmation dialog + try: + ssdp_conf = { + CONF_HOST: self.hostname, + CONF_PORT: DEFAULT_PORT, + CONF_SERIAL: self.serial, + CONF_INSTALLATION_ID: self.installation_id, + } + await validate_input(ssdp_conf) + except AuthenticationError: + return await self.async_step_ssdp_auth() + except CannotConnectError: + return self.async_abort(reason="cannot_connect") + except Exception: + _LOGGER.exception( + "Unexpected error validating SSDP discovery for Victron GX" + ) + return self.async_abort(reason="unknown") + + return await self.async_step_ssdp_confirm() + + async def async_step_ssdp_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm SSDP discovered device.""" + assert self.hostname is not None + assert self.installation_id is not None + + if user_input is not None: + return self.async_create_entry( + title=ENTRY_TITLE_FORMAT.format( + installation_id=self.installation_id, + host=self.hostname, + port=DEFAULT_PORT, + ), + data={ + CONF_HOST: self.hostname, + CONF_PORT: DEFAULT_PORT, + CONF_SERIAL: self.serial, + CONF_INSTALLATION_ID: self.installation_id, + CONF_MODEL: self.model_name, + }, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="ssdp_confirm", + description_placeholders={"name": self.friendly_name or self.hostname}, + ) + + async def async_step_ssdp_auth( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle SSDP auth when credentials are required.""" + assert self.hostname is not None + assert self.installation_id is not None + + errors: dict[str, str] = {} + + if user_input is not None: + _LOGGER.debug( + "SSDP auth user input received: %s", + async_redact_data(user_input, TO_REDACT), + ) + data: dict[str, Any] = { + CONF_HOST: self.hostname, + CONF_PORT: DEFAULT_PORT, + CONF_SERIAL: self.serial, + CONF_INSTALLATION_ID: self.installation_id, + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_PASSWORD: user_input.get(CONF_PASSWORD), + CONF_SSL: user_input.get(CONF_SSL), + } + + try: + await validate_input(data) + _LOGGER.debug("SSDP authentication successful") + except AuthenticationError: + _LOGGER.debug("Authentication failed during SSDP setup", exc_info=True) + errors["base"] = "invalid_auth" + except CannotConnectError: + _LOGGER.debug("Cannot connect during SSDP setup", exc_info=True) + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during SSDP setup") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=ENTRY_TITLE_FORMAT.format( + installation_id=self.installation_id, + host=self.hostname, + port=DEFAULT_PORT, + ), + data=data, + ) + + return self.async_show_form( + step_id="ssdp_auth", + data_schema=self.add_suggested_values_to_schema( + STEP_SSDP_AUTH_DATA_SCHEMA, user_input + ), + errors=errors, + 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 new file mode 100644 index 00000000000..ca806ca5249 --- /dev/null +++ b/homeassistant/components/victron_gx/const.py @@ -0,0 +1,11 @@ +"""Constants for the victron_gx integration.""" + +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..502e78ae351 --- /dev/null +++ b/homeassistant/components/victron_gx/device_tracker.py @@ -0,0 +1,97 @@ +"""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 SourceType, 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.""" + + _attr_source_type = SourceType.GPS + _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 new file mode 100644 index 00000000000..c7257a62601 --- /dev/null +++ b/homeassistant/components/victron_gx/entity.py @@ -0,0 +1,78 @@ +"""Base entity for entities in victron_gx integration.""" + +from abc import abstractmethod +from typing import Any + +from victron_mqtt import Device as VictronVenusDevice, Metric as VictronVenusMetric + +from homeassistant.const import EntityCategory +from homeassistant.core import callback +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 that should be disabled by default +ENTITIES_DISABLE_BY_DEFAULT = ["system_heartbeat"] + + +class VictronBaseEntity(Entity): + """Implementation of a Victron GX base entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__( + self, + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Initialize the entity.""" + self._device = device + self._metric = metric + self._attr_device_info = device_info + self._attr_unique_id = f"{installation_id}_{metric.unique_id}" + self._attr_suggested_display_precision = metric.precision + # 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 + + # Special case for "%" as it should not be coming from the localization file + self._attr_native_unit_of_measurement = ( + "%" if metric.unit_of_measurement == "%" else None + ) + self._attr_entity_category = ( + EntityCategory.DIAGNOSTIC + if metric.generic_short_id in ENTITIES_CATEGORY_DIAGNOSTIC + else None + ) + self._attr_entity_registry_enabled_default = ( + metric.generic_short_id not in ENTITIES_DISABLE_BY_DEFAULT + ) + + @callback + @abstractmethod + def _on_update_cb(self, value: Any) -> None: + """Handle the metric update. Must be implemented by subclasses.""" + + @callback + def _on_update(self, _: VictronVenusMetric, value: Any) -> None: + self._on_update_cb(value) + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self._metric.on_update = self._on_update + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + # Unregister update callback + self._metric.on_update = None + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/victron_gx/hub.py b/homeassistant/components/victron_gx/hub.py new file mode 100644 index 00000000000..80a5af8a998 --- /dev/null +++ b/homeassistant/components/victron_gx/hub.py @@ -0,0 +1,196 @@ +"""Main Hub class.""" + +from collections.abc import Callable +import logging +from typing import TYPE_CHECKING, Any + +from victron_mqtt import ( + AuthenticationError, + CannotConnectError, + Device as VictronVenusDevice, + Hub as VictronVenusHub, + Metric as VictronVenusMetric, + MetricKind, + MetricType, + OperationMode, + VictronEnum, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +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 + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL_SECONDS = 30 + +TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} + +type VictronGxConfigEntry = ConfigEntry[Hub] + +NewMetricCallback = Callable[ + [VictronVenusDevice, VictronVenusMetric, DeviceInfo, str], None +] + + +class Hub: + """Victron MQTT Hub for managing communication and sensors.""" + + def __init__(self, hass: HomeAssistant, entry: VictronGxConfigEntry) -> None: + """Initialize Victron MQTT Hub. + + Args: + hass: Home Assistant instance + entry: ConfigEntry containing configuration + + """ + + _LOGGER.debug( + "Initializing hub. ConfigEntry: %s, data: %s", + entry, + async_redact_data({**entry.data, **entry.options}, TO_REDACT), + ) + config = {**entry.data, **entry.options} + self.hass = hass + self.host = config[CONF_HOST] + + self._hub = VictronVenusHub( + host=self.host, + port=config.get(CONF_PORT, 1883), + username=config.get(CONF_USERNAME) or None, + password=config.get(CONF_PASSWORD) or None, + use_ssl=config.get(CONF_SSL, False), + 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.FULL, + update_frequency_seconds=UPDATE_INTERVAL_SECONDS, + ) + self._hub.on_new_metric = self._on_new_metric + self.new_metric_callbacks: dict[MetricKind, NewMetricCallback] = {} + + async def start(self) -> None: + """Start the Victron MQTT hub.""" + _LOGGER.info("Starting hub") + try: + await self._hub.connect() + except AuthenticationError as auth_error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_failed", + translation_placeholders={"host": self.host}, + ) from auth_error + except CannotConnectError as connect_error: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"host": self.host}, + ) from connect_error + + async def stop(self) -> None: + """Stop the Victron MQTT hub.""" + _LOGGER.info("Stopping hub") + try: + await self._hub.disconnect() + except Exception as err: # noqa: BLE001 + _LOGGER.warning( + "Ignoring error while disconnecting from hub %s during shutdown", + self.host, + exc_info=err, + ) + + def _on_new_metric( + self, + hub: VictronVenusHub, + device: VictronVenusDevice, + metric: VictronVenusMetric, + ) -> None: + _LOGGER.debug("New metric received. Device: %s, Metric: %s", device, metric) + if TYPE_CHECKING: + assert hub.installation_id is not None + device_info = Hub._map_device_info(device, hub.installation_id) + callback = self.new_metric_callbacks.get(metric.metric_kind) + if callback is not None: + callback(device, metric, device_info, hub.installation_id) + + @staticmethod + def _map_device_info( + device: VictronVenusDevice, installation_id: str + ) -> DeviceInfo: + device_info = DeviceInfo( + identifiers={(DOMAIN, f"{installation_id}_{device.unique_id}")}, + manufacturer=( + device.manufacturer + if device.manufacturer is not None + else "Victron Energy" + ), + name=device.name, + model=device.model, + serial_number=device.serial_number, + ) + # 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 == 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: + """Register a callback to handle a new specific metric kind.""" + _LOGGER.debug("Registering NewMetricCallback. kind: %s", kind) + self.new_metric_callbacks[kind] = new_metric_callback + + def unregister_all_new_metric_callbacks(self) -> None: + """Unregister all callbacks to handle new metrics for all metric kinds.""" + _LOGGER.debug("Unregistering NewMetricCallback") + self.new_metric_callbacks.clear() diff --git a/homeassistant/components/victron_gx/manifest.json b/homeassistant/components/victron_gx/manifest.json new file mode 100644 index 00000000000..c78fa8cd29e --- /dev/null +++ b/homeassistant/components/victron_gx/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "victron_gx", + "name": "Victron GX", + "codeowners": ["@tomer-w"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/victron_gx", + "integration_type": "hub", + "iot_class": "local_push", + "quality_scale": "platinum", + "requirements": ["victron-mqtt==2026.4.17"], + "ssdp": [ + { + "X_MqttOnLan": "1", + "manufacturer": "Victron Energy" + } + ] +} diff --git a/homeassistant/components/victron_gx/number.py b/homeassistant/components/victron_gx/number.py new file mode 100644 index 00000000000..378a87be2a2 --- /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, +} + + +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) + if self._attr_device_class is not None: + self._attr_native_unit_of_measurement = metric.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 new file mode 100644 index 00000000000..2e5e179f42d --- /dev/null +++ b/homeassistant/components/victron_gx/quality_scale.yaml @@ -0,0 +1,77 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not have 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: 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: + status: exempt + comment: | + This integration does not have 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: 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: + status: exempt + comment: | + Not relevant. + 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: 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 new file mode 100644 index 00000000000..35a371fbe04 --- /dev/null +++ b/homeassistant/components/victron_gx/sensor.py @@ -0,0 +1,116 @@ +"""Support for Victron GX sensors.""" + +from typing import Any + +from victron_mqtt import ( + Device as VictronVenusDevice, + Metric as VictronVenusMetric, + MetricKind, + MetricNature, + MetricType, + VictronEnum, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +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 # There is no I/O in the entity itself. + +METRIC_TYPE_TO_DEVICE_CLASS: dict[MetricType, SensorDeviceClass] = { + MetricType.POWER: SensorDeviceClass.POWER, + MetricType.APPARENT_POWER: SensorDeviceClass.APPARENT_POWER, + MetricType.ENERGY: SensorDeviceClass.ENERGY, + MetricType.VOLTAGE: SensorDeviceClass.VOLTAGE, + MetricType.CURRENT: SensorDeviceClass.CURRENT, + MetricType.FREQUENCY: SensorDeviceClass.FREQUENCY, + MetricType.ELECTRIC_STORAGE_PERCENTAGE: SensorDeviceClass.BATTERY, + MetricType.TEMPERATURE: SensorDeviceClass.TEMPERATURE, + MetricType.SPEED: SensorDeviceClass.SPEED, + MetricType.LIQUID_VOLUME: SensorDeviceClass.VOLUME_STORAGE, + MetricType.DURATION: SensorDeviceClass.DURATION, + MetricType.ENUM: SensorDeviceClass.ENUM, +} + +METRIC_NATURE_TO_STATE_CLASS: dict[MetricNature, SensorStateClass] = { + MetricNature.MEASUREMENT: SensorStateClass.MEASUREMENT, + MetricNature.TOTAL: SensorStateClass.TOTAL, + MetricNature.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VictronGxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Victron GX 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 sensor metric discovery.""" + async_add_entities( + [ + VictronSensor( + device, + metric, + device_info, + installation_id, + ) + ] + ) + + hub.register_new_metric_callback(MetricKind.SENSOR, on_new_metric) + + +class VictronSensor(VictronBaseEntity, SensorEntity): + """Implementation of a Victron GX sensor.""" + + def __init__( + self, + device: VictronVenusDevice, + metric: VictronVenusMetric, + device_info: DeviceInfo, + installation_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(device, metric, device_info, installation_id) + self._attr_device_class = METRIC_TYPE_TO_DEVICE_CLASS.get(metric.metric_type) + # Enum sensors must not have a state class + if self._attr_device_class == SensorDeviceClass.ENUM: + self._attr_options = metric.enum_values + else: + 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_value = VictronSensor._normalize_value(metric.value) + + @callback + def _on_update_cb(self, value: Any) -> None: + self._attr_native_value = VictronSensor._normalize_value(value) + self.async_write_ha_state() + + @staticmethod + def _normalize_value(value: Any) -> Any: + """Normalize Victron enum values to their enum code.""" + if isinstance(value, VictronEnum): + return value.id + return value diff --git a/homeassistant/components/victron_gx/strings.json b/homeassistant/components/victron_gx/strings.json new file mode 100644 index 00000000000..e3d9a9f4472 --- /dev/null +++ b/homeassistant/components/victron_gx/strings.json @@ -0,0 +1,2007 @@ +{ + "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", + "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_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", + "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_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_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": { + "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%]", + "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%]", + "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": "Authentication is required to connect to {host}.", + "title": "Authenticate Victron GX" + }, + "ssdp_confirm": { + "description": "Do you want to set up the Victron GX device {name}?" + }, + "user": { + "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": "Hostname or IP address of Victron device, usually mDNS name like 'venus.local'", + "password": "Password for the Victron device, default is empty. This is not your VRM password.", + "port": "The MQTT port on the host. Normally it is 1883.", + "ssl": "Indicates whether to use SSL to connect to the Victron device. Normally it is disabled.", + "username": "Username for the MQTT server, default is empty. Not needed by Victron devices. This is only needed if you route your MQTT messages through a non-Victron server and it does require a username." + } + } + } + }, + "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" + }, + "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%]" + } + }, + "device_tracker": { + "gps_location": { + "name": "[%key:common::config_flow::data::location%]" + } + }, + "number": { + "alternator_charge_current_limit": { + "name": "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" + }, + "switch_output_dimming": { + "name": "Dimming" + }, + "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%]" + } + }, + "system_ess_batterylife_state": { + "name": "ESS BatteryLife state", + "state": { + "keep_batteries_charged": "'Keep batteries charged' mode enabled", + "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 battery with 5 amps", + "sustain": "Multi/Quattro is in sustain", + "with_battery_life": "Optimized mode with 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": "[%key:component::victron_gx::common::current_on_phase%]" + }, + "acload_energy_forward": { + "name": "[%key:component::victron_gx::common::consumption%]" + }, + "acload_energy_forward_phase": { + "name": "[%key:component::victron_gx::common::consumption_on_phase%]" + }, + "acload_frequency": { + "name": "[%key:component::victron_gx::common::frequency%]" + }, + "acload_power": { + "name": "[%key:component::victron_gx::common::power%]" + }, + "acload_power_phase": { + "name": "[%key:component::victron_gx::common::power_on_phase%]" + }, + "acload_voltage": { + "name": "[%key:component::victron_gx::common::voltage%]" + }, + "acload_voltage_phase": { + "name": "[%key:component::victron_gx::common::voltage_on_phase%]" + }, + "alternator_dc_current": { + "name": "[%key:component::victron_gx::common::dc_output_current%]" + }, + "alternator_dc_power": { + "name": "[%key:component::victron_gx::common::dc_output_power%]" + }, + "alternator_dc_voltage": { + "name": "[%key:component::victron_gx::common::dc_output_voltage%]" + }, + "alternator_input_current": { + "name": "[%key:component::victron_gx::common::input_current%]" + }, + "alternator_input_power": { + "name": "[%key:component::victron_gx::common::input_power%]" + }, + "alternator_input_voltage": { + "name": "[%key:component::victron_gx::common::input_voltage%]" + }, + "alternator_state": { + "name": "[%key:component::victron_gx::common::state%]", + "state": { + "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": { + "name": "Auxiliary battery voltage" + }, + "battery_automatic_syncs": { + "name": "Automatic syncs", + "unit_of_measurement": "syncs" + }, + "battery_average_discharge": { + "name": "Average discharge" + }, + "battery_capacity": { + "name": "Capacity", + "unit_of_measurement": "Ah" + }, + "battery_cell_cell_id_voltage": { + "name": "Cell {cell_id} voltage" + }, + "battery_cell_imbalance": { + "name": "Cell imbalance", + "state": { + "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": { + "name": "Cell voltage deviation" + }, + "battery_charged_energy": { + "name": "Charged energy" + }, + "battery_consumed_amphours": { + "name": "Consumed amp-hours", + "unit_of_measurement": "Ah" + }, + "battery_cumulative_ah_drawn": { + "name": "Cumulative Ah drawn", + "unit_of_measurement": "Ah" + }, + "battery_current": { + "name": "DC bus current" + }, + "battery_deepest_discharge": { + "name": "Deepest discharge" + }, + "battery_discharged_energy": { + "name": "Discharged energy" + }, + "battery_high_charge_current": { + "name": "High charge current", + "state": { + "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": "[%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": "[%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" + }, + "battery_internal_failure": { + "name": "Internal failure", + "state": { + "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": { + "name": "Last discharge" + }, + "battery_low_cell_voltage": { + "name": "Low cell voltage", + "state": { + "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": "[%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": { + "name": "Maximum cell temperature" + }, + "battery_max_cell_voltage": { + "name": "Maximum cell voltage" + }, + "battery_max_charge_current": { + "name": "Maximum allowed charge current" + }, + "battery_max_charge_voltage": { + "name": "Maximum allowed charging voltage" + }, + "battery_max_discharge_current": { + "name": "Maximum allowed discharge current" + }, + "battery_max_temperature_cell_id": { + "name": "Maximum temperature cell ID" + }, + "battery_max_voltage_cell_id": { + "name": "Maximum voltage cell ID" + }, + "battery_maximum_voltage": { + "name": "Maximum voltage" + }, + "battery_mid_voltage": { + "name": "DC bus mid voltage" + }, + "battery_mid_voltage_deviation": { + "name": "DC bus mid voltage deviation" + }, + "battery_min_cell_temperature": { + "name": "Minimum cell temperature" + }, + "battery_min_cell_voltage": { + "name": "Minimum cell voltage" + }, + "battery_min_temperature_cell_id": { + "name": "Minimum temperature cell ID" + }, + "battery_min_voltage_cell_id": { + "name": "Minimum voltage cell ID" + }, + "battery_minimum_voltage": { + "name": "Minimum voltage" + }, + "battery_nr_modules_blocking_charge": { + "name": "Number of modules blocking charge", + "unit_of_measurement": "modules" + }, + "battery_nr_modules_blocking_discharge": { + "name": "Number of modules blocking discharge", + "unit_of_measurement": "modules" + }, + "battery_nr_modules_offline": { + "name": "Number of modules offline", + "unit_of_measurement": "modules" + }, + "battery_nr_modules_online": { + "name": "Number of modules online", + "unit_of_measurement": "modules" + }, + "battery_power": { + "name": "[%key:component::victron_gx::common::power%]" + }, + "battery_soc": { + "name": "Charge" + }, + "battery_soh": { + "name": "State of health" + }, + "battery_temperature": { + "name": "[%key:component::victron_gx::common::temperature%]" + }, + "battery_time_since_last_full_charge": { + "name": "Time since last full charge", + "unit_of_measurement": "seconds" + }, + "battery_time_to_go": { + "name": "Time to go" + }, + "battery_total_charge_cycles": { + "name": "Total charge cycles", + "unit_of_measurement": "cycles" + }, + "battery_voltage": { + "name": "DC bus voltage" + }, + "charge_mode": { + "name": "Charge mode" + }, + "charger_ac_in_current_phase": { + "name": "AC input current {phase}" + }, + "charger_dc_current_output": { + "name": "DC output {output} current" + }, + "charger_dc_voltage_output": { + "name": "DC output {output} voltage" + }, + "charger_error_code": { + "name": "[%key:component::victron_gx::common::error_code%]", + "state": { + "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": { + "name": "Number of outputs", + "unit_of_measurement": "outputs" + }, + "charger_state": { + "name": "[%key:component::victron_gx::common::state%]", + "state": { + "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": "[%key:component::victron_gx::common::dc_output_current%]" + }, + "dcdc_dc_power": { + "name": "[%key:component::victron_gx::common::dc_output_power%]" + }, + "dcdc_dc_voltage": { + "name": "[%key:component::victron_gx::common::dc_output_voltage%]" + }, + "dcdc_input_current": { + "name": "[%key:component::victron_gx::common::input_current%]" + }, + "dcdc_input_power": { + "name": "[%key:component::victron_gx::common::input_power%]" + }, + "dcdc_input_voltage": { + "name": "[%key:component::victron_gx::common::input_voltage%]" + }, + "dcdc_state": { + "name": "[%key:component::victron_gx::common::state%]", + "state": { + "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": "[%key:component::victron_gx::common::current%]" + }, + "dcload_power": { + "name": "[%key:component::victron_gx::common::power%]" + }, + "dcload_voltage": { + "name": "[%key:component::victron_gx::common::voltage%]" + }, + "dcsystem_aux_voltage": { + "name": "Auxiliary voltage" + }, + "dcsystem_current": { + "name": "[%key:component::victron_gx::common::current%]" + }, + "dcsystem_power": { + "name": "[%key:component::victron_gx::common::power%]" + }, + "dcsystem_voltage": { + "name": "[%key:component::victron_gx::common::voltage%]" + }, + "digitalinput_alarm": { + "name": "[%key:component::victron_gx::common::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%]" + } + }, + "digitalinput_input_state_raw": { + "name": "Raw state", + "state": { + "high_open": "High/open", + "low_closed": "Low/closed" + } + }, + "digitalinput_state": { + "name": "[%key:component::victron_gx::common::state%]", + "state": { + "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": "[%key:common::state::stopped%]", + "yes": "[%key:common::state::yes%]" + } + }, + "digitalinput_type": { + "name": "Type", + "state": { + "bilge_alarm": "Bilge alarm", + "bilge_pump": "Bilge pump", + "burglar_alarm": "Burglar alarm", + "co2_alarm": "CO2 alarm", + "disabled": "[%key:common::state::disabled%]", + "door_alarm": "Door alarm", + "fire_alarm": "Fire alarm", + "generator": "[%key:component::victron_gx::common::generator%]", + "pulse_meter": "Pulse meter", + "smoke_alarm": "Smoke alarm", + "touch_input_control": "Touch input control" + } + }, + "evcharger_current": { + "name": "[%key:component::victron_gx::common::current%]" + }, + "evcharger_max_set_current": { + "name": "Maximum set current" + }, + "evcharger_min_set_current": { + "name": "Minimum set current" + }, + "evcharger_position": { + "name": "Position", + "state": { + "ac_input": "AC input", + "ac_out": "AC out" + } + }, + "evcharger_power": { + "name": "[%key:component::victron_gx::common::power%]" + }, + "evcharger_power_phase": { + "name": "[%key:component::victron_gx::common::power_phase%]" + }, + "evcharger_session_cost": { + "name": "Last session cost", + "unit_of_measurement": "$" + }, + "evcharger_session_energy": { + "name": "Last session energy" + }, + "evcharger_session_time": { + "name": "Last session time" + }, + "evcharger_status": { + "name": "Status", + "state": { + "charged": "Charged", + "charging": "[%key:common::state::charging%]", + "charging_limit": "Charging limit", + "connected": "[%key:common::state::connected%]", + "cp_input_test_error": "CP input test error", + "disconnected": "[%key:common::state::disconnected%]", + "ground_test_error": "Ground test error", + "low_soc": "Low SoC", + "overheating_detected": "Overheating detected", + "overvoltage_detected": "Overvoltage detected", + "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", + "switching_to_3_phase": "Switching to 3 phase", + "undervoltage_detected": "Undervoltage detected", + "waiting_for_rfid": "Waiting for RFID", + "waiting_for_start": "Waiting for start", + "waiting_for_sun": "Waiting for sun", + "welded_contacts_test_error": "Welded contacts test error" + } + }, + "evcharger_total_energy": { + "name": "Total energy" + }, + "generator_run_state": { + "name": "Run state", + "state": { + "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": { + "name": "Service counter" + }, + "generator_today_runtime": { + "name": "Today runtime" + }, + "generator_total_runtime": { + "name": "Total runtime" + }, + "gps_nrofsatellites": { + "name": "Number of satellites", + "unit_of_measurement": "satellites" + }, + "grid_current": { + "name": "[%key:component::victron_gx::common::current%]" + }, + "grid_current_n": { + "name": "Current on N" + }, + "grid_current_phase": { + "name": "[%key:component::victron_gx::common::current_on_phase%]" + }, + "grid_energy_forward": { + "name": "[%key:component::victron_gx::common::consumption%]" + }, + "grid_energy_forward_phase": { + "name": "Grid consumption on {phase}" + }, + "grid_energy_reverse": { + "name": "Feed-in" + }, + "grid_energy_reverse_phase": { + "name": "Feed-in on {phase}" + }, + "grid_frequency": { + "name": "[%key:component::victron_gx::common::frequency%]" + }, + "grid_power": { + "name": "[%key:component::victron_gx::common::power%]" + }, + "grid_power_factor": { + "name": "Power factor" + }, + "grid_power_factor_phase": { + "name": "Power factor on {phase}" + }, + "grid_power_phase": { + "name": "[%key:component::victron_gx::common::power_on_phase%]" + }, + "grid_voltage": { + "name": "[%key:component::victron_gx::common::voltage%]" + }, + "grid_voltage_pen": { + "name": "Voltage on PEN" + }, + "grid_voltage_phase": { + "name": "[%key:component::victron_gx::common::voltage_on_phase%]" + }, + "grid_voltage_phase_next_phase": { + "name": "Voltage {phase} to {next_phase}" + }, + "heatpump_current": { + "name": "[%key:component::victron_gx::common::current%]" + }, + "heatpump_current_phase": { + "name": "[%key:component::victron_gx::common::current_on_phase%]" + }, + "heatpump_energy_forward": { + "name": "[%key:component::victron_gx::common::consumption%]" + }, + "heatpump_energy_forward_phase": { + "name": "[%key:component::victron_gx::common::consumption_on_phase%]" + }, + "heatpump_frequency": { + "name": "[%key:component::victron_gx::common::frequency%]" + }, + "heatpump_power": { + "name": "[%key:component::victron_gx::common::power%]" + }, + "heatpump_power_phase": { + "name": "[%key:component::victron_gx::common::power_on_phase%]" + }, + "heatpump_voltage": { + "name": "[%key:component::victron_gx::common::voltage%]" + }, + "heatpump_voltage_phase": { + "name": "[%key:component::victron_gx::common::voltage_on_phase%]" + }, + "inverter_output_apparent_power_phase": { + "name": "[%key:component::victron_gx::common::output_apparent_power_phase%]" + }, + "inverter_output_current_phase": { + "name": "[%key:component::victron_gx::common::output_current_phase%]" + }, + "inverter_output_power_phase": { + "name": "[%key:component::victron_gx::common::output_power_phase%]" + }, + "inverter_output_voltage_phase": { + "name": "[%key:component::victron_gx::common::output_voltage_phase%]" + }, + "inverter_pv_power_total": { + "name": "[%key:component::victron_gx::common::pv_power_total%]" + }, + "inverter_pv_voltage": { + "name": "[%key:component::victron_gx::common::pv_bus_voltage%]" + }, + "inverter_state": { + "name": "[%key:component::victron_gx::common::state%]", + "state": { + "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": "[%key:component::victron_gx::common::total_pv_yield_user%]" + }, + "multi_acin1_to_acout": { + "name": "AC-in-1 to AC-out" + }, + "multi_acin1_to_inverter": { + "name": "AC-in-1 to inverter" + }, + "multi_acin_current_phase": { + "name": "[%key:component::victron_gx::common::current_phase%]" + }, + "multi_acin_power_phase": { + "name": "[%key:component::victron_gx::common::power_on_phase%]" + }, + "multi_acin_voltage_phase": { + "name": "[%key:component::victron_gx::common::voltage_on_phase%]" + }, + "multi_acout_output_current_phase": { + "name": "AC-out-{output} current on {phase}" + }, + "multi_acout_output_power_phase": { + "name": "AC-out-{output} power on {phase}" + }, + "multi_acout_output_voltage_phase": { + "name": "AC-out-{output} voltage on {phase}" + }, + "multi_acout_to_acin1": { + "name": "AC-out to AC-in-1" + }, + "multi_acout_to_inverter": { + "name": "AC-out to inverter" + }, + "multi_active_input": { + "name": "[%key:component::victron_gx::common::active_ac_input%]", + "state": { + "ac_input_1": "AC input 1", + "ac_input_2": "AC input 2", + "disconnected": "[%key:common::state::disconnected%]" + } + }, + "multi_dc_temperature": { + "name": "[%key:component::victron_gx::common::dc_temperature%]" + }, + "multi_ess_mode": { + "name": "[%key:component::victron_gx::common::ess_mode%]", + "state": { + "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": { + "name": "Inverter power setpoint" + }, + "multi_inverter_to_acin1": { + "name": "Inverter to AC-in-1" + }, + "multi_inverter_to_acout": { + "name": "Inverter to AC-out" + }, + "multi_max_power_today": { + "name": "[%key:component::victron_gx::common::max_power_today%]" + }, + "multi_max_power_yesterday": { + "name": "[%key:component::victron_gx::common::max_power_yesterday%]" + }, + "multi_mppt_mppt_id_yield_today": { + "name": "MPPT {mppt_id} yield today" + }, + "multi_mppt_mppt_id_yield_yesterday": { + "name": "MPPT {mppt_id} yield yesterday" + }, + "multi_mppt_mpptnumber_power": { + "name": "MPPT {mpptnumber} power" + }, + "multi_mppt_mpptnumber_state": { + "state": { + "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": { + "name": "MPPT {mpptnumber} PV voltage" + }, + "multi_phases": { + "name": "Phases", + "unit_of_measurement": "phases" + }, + "multi_pv_power_total": { + "name": "[%key:component::victron_gx::common::pv_power_total%]" + }, + "multi_solar_to_acin1": { + "name": "Solar to AC-in-1" + }, + "multi_solar_to_acout": { + "name": "Solar to AC-out" + }, + "multi_solar_to_battery": { + "name": "Solar to battery" + }, + "multi_state": { + "name": "[%key:component::victron_gx::common::state%]", + "state": { + "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": "[%key:component::victron_gx::common::total_pv_yield_user%]" + }, + "multi_yield_today": { + "name": "[%key:component::victron_gx::common::yield_today%]" + }, + "multi_yield_yesterday": { + "name": "[%key:component::victron_gx::common::yield_yesterday%]" + }, + "platform_venus_firmware_available_version": { + "name": "Available version" + }, + "platform_venus_firmware_installed_version": { + "name": "Installed version" + }, + "pvinverter_current_phase": { + "name": "[%key:component::victron_gx::common::current_phase%]" + }, + "pvinverter_power_phase": { + "name": "[%key:component::victron_gx::common::power_phase%]" + }, + "pvinverter_power_total": { + "name": "Power total" + }, + "pvinverter_voltage_phase": { + "name": "Voltage {phase}" + }, + "pvinverter_yield_phase": { + "name": "Yield {phase}" + }, + "pvinverter_yield_total": { + "name": "[%key:component::victron_gx::common::total_yield%]" + }, + "solarcharger_current": { + "name": "PV bus current" + }, + "solarcharger_dc_current": { + "name": "DC (battery) bus current" + }, + "solarcharger_dc_voltage": { + "name": "DC (battery) bus voltage" + }, + "solarcharger_device_off_reason": { + "name": "Device-off reason", + "state": { + "active_alarm": "Active alarm", + "analysing_input_voltage": "Analysing input voltage", + "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", + "none": "-", + "protective_action": "Protection active", + "remote_input": "Remote input", + "signal_from_bms": "Signal from BMS", + "switched_off_device_mode_register": "Switched off (device mode register)", + "switched_off_power_switch": "Switched off (power switch)" + } + }, + "solarcharger_error_code": { + "name": "[%key:component::victron_gx::common::error_code%]", + "state": { + "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": { + "name": "Load bus current" + }, + "solarcharger_max_battery_voltage_today": { + "name": "Max battery voltage today" + }, + "solarcharger_max_power_today": { + "name": "[%key:component::victron_gx::common::max_power_today%]" + }, + "solarcharger_max_power_yesterday": { + "name": "[%key:component::victron_gx::common::max_power_yesterday%]" + }, + "solarcharger_min_battery_voltage_today": { + "name": "Min battery voltage today" + }, + "solarcharger_mppt_operation_mode": { + "name": "MPPT operation mode", + "state": { + "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_state": { + "name": "[%key:component::victron_gx::common::state%]", + "state": { + "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": { + "name": "Time in absorption today" + }, + "solarcharger_time_in_bulk_today": { + "name": "Time in bulk today" + }, + "solarcharger_time_in_float_today": { + "name": "Time in float today" + }, + "solarcharger_tracker_tracker_max_power_today": { + "name": "Tracker {tracker} max power today" + }, + "solarcharger_tracker_tracker_max_voltage_today": { + "name": "Tracker {tracker} max voltage today" + }, + "solarcharger_tracker_tracker_name": { + "name": "PV tracker {tracker} name" + }, + "solarcharger_tracker_tracker_operation_mode": { + "name": "PV tracker {tracker} operation mode", + "state": { + "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": { + "name": "PV tracker {tracker} power" + }, + "solarcharger_tracker_tracker_voltage": { + "name": "PV tracker {tracker} voltage" + }, + "solarcharger_tracker_tracker_yield_today": { + "name": "Tracker {tracker} yield today" + }, + "solarcharger_voltage": { + "name": "[%key:component::victron_gx::common::pv_bus_voltage%]" + }, + "solarcharger_yield_power": { + "name": "PV yield power" + }, + "solarcharger_yield_today": { + "name": "[%key:component::victron_gx::common::yield_today%]" + }, + "solarcharger_yield_total": { + "name": "[%key:component::victron_gx::common::total_yield%]" + }, + "solarcharger_yield_yesterday": { + "name": "[%key:component::victron_gx::common::yield_yesterday%]" + }, + "system_ac_active_input_source": { + "name": "AC active input source", + "state": { + "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_loads_phase": { + "name": "AC loads on {phase}" + }, + "system_consumption_current_phase": { + "name": "Consumption current {phase}" + }, + "system_consumption_on_output_phases": { + "name": "Consumption on output phases", + "unit_of_measurement": "phases" + }, + "system_consumption_phases": { + "name": "Consumption phases", + "unit_of_measurement": "phases" + }, + "system_consumption_power_phase": { + "name": "Consumption power {phase}" + }, + "system_control_active_soc_limit": { + "name": "Active SoC limit" + }, + "system_control_scheduled_soc": { + "name": "Scheduled SoC" + }, + "system_critical_loads_phase": { + "name": "Critical loads on {phase}" + }, + "system_dc_alternator_power": { + "name": "DC alternator power" + }, + "system_dc_battery_charge_energy": { + "name": "DC battery charge energy" + }, + "system_dc_battery_current": { + "name": "DC battery current" + }, + "system_dc_battery_discharge_energy": { + "name": "DC battery discharge energy" + }, + "system_dc_battery_power": { + "name": "DC battery power" + }, + "system_dc_battery_soc": { + "name": "DC battery charge" + }, + "system_dc_battery_state": { + "name": "DC battery state", + "state": { + "charging": "[%key:common::state::charging%]", + "discharging": "[%key:common::state::discharging%]", + "idle": "[%key:common::state::idle%]" + } + }, + "system_dc_battery_voltage": { + "name": "DC battery voltage" + }, + "system_dc_consumption": { + "name": "DC consumption" + }, + "system_dc_pv_current": { + "name": "PV current" + }, + "system_dc_pv_energy": { + "name": "PV energy" + }, + "system_dc_pv_power": { + "name": "PV power" + }, + "system_dynamicess_available_overhead": { + "name": "Dynamic ESS available overhead" + }, + "system_dynamicess_error": { + "name": "Dynamic ESS error", + "state": { + "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" + } + }, + "system_dynamicess_last_scheduled_end": { + "name": "Dynamic ESS last scheduled end" + }, + "system_dynamicess_last_scheduled_start": { + "name": "Dynamic ESS last scheduled start" + }, + "system_dynamicess_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" + } + }, + "system_dynamicess_restrictions": { + "name": "Dynamic ESS restrictions", + "state": { + "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" + } + }, + "system_dynamicess_schedule_count": { + "name": "Dynamic ESS number of schedules", + "unit_of_measurement": "schedules" + }, + "system_dynamicess_strategy": { + "name": "Dynamic ESS strategy", + "state": { + "probattery": "Pro battery", + "progrid": "Pro grid", + "selfconsume": "Self-consume", + "targetsoc": "Target SoC" + } + }, + "system_dynamicess_target_soc": { + "name": "Dynamic ESS target SoC" + }, + "system_generator_load_phase": { + "name": "Genset load {phase}" + }, + "system_grid_current_phase": { + "name": "Grid current {phase}" + }, + "system_grid_phases": { + "name": "Grid phases", + "unit_of_measurement": "phases" + }, + "system_grid_power_phase": { + "name": "Grid power {phase}" + }, + "system_heartbeat": { + "name": "GX system heartbeat" + }, + "system_pv_on_output_current_phase": { + "name": "PV on output current {phase}" + }, + "system_pv_on_output_phases": { + "name": "PV on output phases", + "unit_of_measurement": "phases" + }, + "system_pv_on_output_power_phase": { + "name": "PV on output power {phase}" + }, + "system_relay_relay_custom_name": { + "name": "Relay {relay} custom name" + }, + "system_state": { + "name": "System state", + "state": { + "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": "[%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", + "fuel": "Fuel", + "gasoline": "Gasoline", + "hydraulic_oil": "Hydraulic oil", + "live_well": "Live well", + "lng": "Liquid natural gas (LNG)", + "lpg": "Liquid petroleum gas (LPG)", + "oil": "Oil", + "raw_water": "Raw water", + "waste_water": "Waste water" + } + }, + "tank_level": { + "name": "Level" + }, + "tank_remaining": { + "name": "Remaining" + }, + "tank_temperature": { + "name": "[%key:component::victron_gx::common::temperature%]" + }, + "temperature_battery_voltage": { + "name": "[%key:component::victron_gx::common::sensor_battery_voltage%]" + }, + "temperature_humidity": { + "name": "Humidity" + }, + "temperature_pressure": { + "name": "Pressure" + }, + "temperature_status": { + "name": "Sensor status", + "state": { + "disconnected": "[%key:common::state::disconnected%]", + "ok": "[%key:component::victron_gx::common::ok%]", + "reverse_polarity": "Reverse polarity", + "short_circuited": "Short circuited", + "unknown": "[%key:component::victron_gx::common::unknown%]" + } + }, + "temperature_temperature": { + "name": "[%key:component::victron_gx::common::temperature%]" + }, + "temperature_type": { + "name": "Sensor type", + "state": { + "battery": "Battery", + "freezer": "Freezer", + "fridge": "Fridge", + "generic": "Generic", + "outdoor": "Outdoor", + "room": "Room", + "water_heater": "Water heater" + } + }, + "vebus_device_device_number_input_power_l1": { + "name": "{device_number} line 1 input power" + }, + "vebus_device_device_number_input_power_phase": { + "name": "{device_number} line {phase} input power" + }, + "vebus_device_device_number_inverted_power": { + "name": "{device_number} inverted power" + }, + "vebus_device_device_number_output_power_l1": { + "name": "{device_number} line 1 output power" + }, + "vebus_device_device_number_output_power_phase": { + "name": "{device_number} line {phase} output power" + }, + "vebus_energy_ac_in1_to_ac_out": { + "name": "Energy from AC-in-1 to AC-out" + }, + "vebus_energy_ac_in1_to_inverter": { + "name": "Energy from AC-in-1 to inverter" + }, + "vebus_energy_ac_in2_to_ac_out": { + "name": "Energy from AC-in-2 to AC-out" + }, + "vebus_energy_ac_in2_to_inverter": { + "name": "Energy from AC-in-2 to inverter" + }, + "vebus_energy_ac_out_to_ac_in1": { + "name": "Energy from AC-out to AC-in-1" + }, + "vebus_energy_ac_out_to_ac_in2": { + "name": "Energy from AC-out to AC-in-2" + }, + "vebus_energy_inverter_to_ac_in1": { + "name": "Energy from inverter to AC-in-1" + }, + "vebus_energy_inverter_to_ac_in2": { + "name": "Energy from inverter to AC-in-2" + }, + "vebus_energy_inverter_to_ac_out": { + "name": "Energy from inverter to AC-out" + }, + "vebus_energy_out_to_inverter": { + "name": "Energy from out to inverter" + }, + "vebus_inverter_active_input": { + "name": "[%key:component::victron_gx::common::active_ac_input%]", + "state": { + "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": "[%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": "[%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": "[%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": "[%key:component::victron_gx::common::high_temperature_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%]" + } + }, + "vebus_inverter_alarm_low_battery": { + "name": "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%]" + } + }, + "vebus_inverter_alarm_overload": { + "name": "[%key:component::victron_gx::common::overload_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%]" + } + }, + "vebus_inverter_alarm_phase_rotation": { + "name": "Phase rotation 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%]" + } + }, + "vebus_inverter_alarm_ripple": { + "name": "[%key:component::victron_gx::common::ripple_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%]" + } + }, + "vebus_inverter_alarm_temperature_sensor": { + "name": "Temperature sensor 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%]" + } + }, + "vebus_inverter_alarm_voltage_sensor": { + "name": "Voltage sensor 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%]" + } + }, + "vebus_inverter_current_limit": { + "name": "[%key:component::victron_gx::common::current_limit%]" + }, + "vebus_inverter_dc_current": { + "name": "DC current" + }, + "vebus_inverter_dc_power": { + "name": "DC power" + }, + "vebus_inverter_dc_temperature": { + "name": "[%key:component::victron_gx::common::dc_temperature%]" + }, + "vebus_inverter_dc_voltage": { + "name": "DC voltage" + }, + "vebus_inverter_ignoreacin1_state": { + "name": "State of ignore AC-in-1", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]" + } + }, + "vebus_inverter_input_apparent_power_phase": { + "name": "Input apparent power {phase}" + }, + "vebus_inverter_input_current_phase": { + "name": "Input current {phase}" + }, + "vebus_inverter_input_frequency_phase": { + "name": "Input frequency {phase}" + }, + "vebus_inverter_input_power_phase": { + "name": "Input power {phase}" + }, + "vebus_inverter_input_voltage_phase": { + "name": "Input voltage {phase}" + }, + "vebus_inverter_output_apparent_power_phase": { + "name": "[%key:component::victron_gx::common::output_apparent_power_phase%]" + }, + "vebus_inverter_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": "[%key:component::victron_gx::common::output_power_phase%]" + }, + "vebus_inverter_output_voltage_phase": { + "name": "[%key:component::victron_gx::common::output_voltage_phase%]" + }, + "vebus_inverter_state": { + "name": "[%key:component::victron_gx::common::state%]", + "state": { + "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" + }, + "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": "PV DC overvoltage feed-in" + }, + "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_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..9121fde2d12 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 diff --git a/homeassistant/components/victron_remote_monitoring/energy.py b/homeassistant/components/victron_remote_monitoring/energy.py index b3209703115..c10ce51908f 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 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/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/vivotek/camera.py b/homeassistant/components/vivotek/camera.py index 5b22ba41349..7990bce2105 100644 --- a/homeassistant/components/vivotek/camera.py +++ b/homeassistant/components/vivotek/camera.py @@ -1,7 +1,5 @@ """Support for Vivotek IP Cameras.""" -from __future__ import annotations - import logging from typing import TYPE_CHECKING 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..8286f425515 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 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..5e512d41fd0 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 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/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/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/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..a9cd3afb8e2 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 @@ -73,6 +71,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: VoipConfigEntry) -> bool ) _LOGGER.debug("Listening for VoIP calls on port %s", sip_port) + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data hass.data[DOMAIN] = DomainData(transport, protocol, devices) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 14333c33be5..c12a83fd263 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -1,6 +1,5 @@ """Assist satellite entity for VoIP integration.""" - -from __future__ import annotations +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio from datetime import timedelta diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py index 34dac4b6068..07aa4a9d5c6 100644 --- a/homeassistant/components/voip/binary_sensor.py +++ b/homeassistant/components/voip/binary_sensor.py @@ -1,7 +1,5 @@ """Binary sensor for VoIP.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.components.binary_sensor import ( @@ -27,6 +25,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP binary sensor entities.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data domain_data: DomainData = hass.data[DOMAIN] @callback 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..088ca6a13eb 100644 --- a/homeassistant/components/voip/devices.py +++ b/homeassistant/components/voip/devices.py @@ -1,7 +1,5 @@ """Class to manage devices.""" -from __future__ import annotations - from collections.abc import Callable, Iterator from dataclasses import dataclass, field import logging 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/repairs.py b/homeassistant/components/voip/repairs.py index 600ea4d66fb..6ea702c0e9a 100644 --- a/homeassistant/components/voip/repairs.py +++ b/homeassistant/components/voip/repairs.py @@ -1,7 +1,5 @@ """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, ) diff --git a/homeassistant/components/voip/select.py b/homeassistant/components/voip/select.py index 8c9668b09ef..ed1c691a72a 100644 --- a/homeassistant/components/voip/select.py +++ b/homeassistant/components/voip/select.py @@ -1,7 +1,5 @@ """Select entities for VoIP integration.""" -from __future__ import annotations - from typing import TYPE_CHECKING from homeassistant.components.assist_pipeline import ( @@ -26,6 +24,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP switch entities.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data domain_data: DomainData = hass.data[DOMAIN] @callback diff --git a/homeassistant/components/voip/switch.py b/homeassistant/components/voip/switch.py index 7690b8f125c..392578ac833 100644 --- a/homeassistant/components/voip/switch.py +++ b/homeassistant/components/voip/switch.py @@ -1,7 +1,5 @@ """VoIP switch entities.""" -from __future__ import annotations - from typing import TYPE_CHECKING, Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription @@ -25,6 +23,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up VoIP switch entities.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data domain_data: DomainData = hass.data[DOMAIN] @callback 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/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/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/button.py b/homeassistant/components/wake_on_lan/button.py index e9cf69b1fe7..5128740c9eb 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 diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py index 16df34c1d1b..da639769fc5 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: 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..87aab42e797 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 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/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 7558ddecc98..249810a44ca 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 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..36124ab455e 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -1,7 +1,5 @@ """Home Assistant component for accessing the Wallbox Portal API. The lock component creates a lock entity.""" -from __future__ import annotations - from typing import Any from homeassistant.components.lock import LockEntity, LockEntityDescription diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 6bc37778a61..5c5b35c63f2 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 diff --git a/homeassistant/components/wallbox/select.py b/homeassistant/components/wallbox/select.py index 8d4cf252344..7d86bfcc782 100644 --- a/homeassistant/components/wallbox/select.py +++ b/homeassistant/components/wallbox/select.py @@ -1,7 +1,5 @@ """Home Assistant component for accessing the Wallbox Portal API. The switch component creates a switch entity.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from dataclasses import dataclass diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index b59e1e5319d..db6471e75b3 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -1,7 +1,5 @@ """Home Assistant component for accessing the Wallbox Portal API. The sensor component creates multiple sensors regarding wallbox performance.""" -from __future__ import annotations - from dataclasses import dataclass from typing import cast diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index 74f1783f539..ba4863f7674 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -1,7 +1,5 @@ """Home Assistant component for accessing the Wallbox Portal API. The switch component creates a switch entity.""" -from __future__ import annotations - from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index ae5ed197b07..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 @@ -40,10 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> bool entry.runtime_data = {} - for subentry in entry.subentries.values(): - if subentry.subentry_type != SUBENTRY_TYPE_STATION: - continue - + for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_STATION): # Create a coordinator for each station subentry coordinator = WAQIDataUpdateCoordinator(hass, entry, subentry, client) await coordinator.async_config_entry_first_refresh() 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 46362df0654..df1286d208c 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -1,22 +1,21 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted water heaters.", - "condition_behavior_name": "Behavior", - "condition_threshold_description": "What to test for and threshold values.", - "condition_threshold_name": "Threshold configuration", - "trigger_behavior_description": "The behavior of the targeted water heaters to trigger on.", - "trigger_behavior_name": "Behavior", - "trigger_threshold_changed_description": "Which changes to trigger on and threshold values.", - "trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.", - "trigger_threshold_name": "Threshold configuration" + "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": { "is_off": { "description": "Tests if one or more water heaters are off.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::condition_behavior_description%]", "name": "[%key:component::water_heater::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::water_heater::common::condition_for_name%]" } }, "name": "Water heater is off" @@ -25,8 +24,10 @@ "description": "Tests if one or more water heaters are on.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::condition_behavior_description%]", "name": "[%key:component::water_heater::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::water_heater::common::condition_for_name%]" } }, "name": "Water heater is on" @@ -35,9 +36,11 @@ "description": "Tests if one or more water heaters are set to a specific operation mode.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::condition_behavior_description%]", "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" @@ -49,11 +52,12 @@ "description": "Tests the temperature setpoint of one or more water heaters.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::condition_behavior_description%]", "name": "[%key:component::water_heater::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::water_heater::common::condition_for_name%]" + }, "threshold": { - "description": "[%key:component::water_heater::common::condition_threshold_description%]", "name": "[%key:component::water_heater::common::condition_threshold_name%]" } }, @@ -127,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.", @@ -192,9 +181,11 @@ "description": "Triggers after the operation mode of one or more water heaters changes to a specific mode.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::trigger_behavior_description%]", "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,7 +197,6 @@ "description": "Triggers after the temperature setpoint of one or more water heaters changes.", "fields": { "threshold": { - "description": "[%key:component::water_heater::common::trigger_threshold_changed_description%]", "name": "[%key:component::water_heater::common::trigger_threshold_name%]" } }, @@ -216,11 +206,12 @@ "description": "Triggers after the temperature setpoint of one or more water heaters crosses a threshold.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::trigger_behavior_description%]", "name": "[%key:component::water_heater::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::water_heater::common::trigger_for_name%]" + }, "threshold": { - "description": "[%key:component::water_heater::common::trigger_threshold_crossed_description%]", "name": "[%key:component::water_heater::common::trigger_threshold_name%]" } }, @@ -230,8 +221,10 @@ "description": "Triggers after one or more water heaters turn off.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::trigger_behavior_description%]", "name": "[%key:component::water_heater::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::water_heater::common::trigger_for_name%]" } }, "name": "Water heater turned off" @@ -240,8 +233,10 @@ "description": "Triggers after one or more water heaters turn on, regardless of the operation mode.", "fields": { "behavior": { - "description": "[%key:component::water_heater::common::trigger_behavior_description%]", "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..72b5efb741b 100644 --- a/homeassistant/components/water_heater/trigger.py +++ b/homeassistant/components/water_heater/trigger.py @@ -60,6 +60,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 diff --git a/homeassistant/components/water_heater/triggers.yaml b/homeassistant/components/water_heater/triggers.yaml index 581b7dbb58c..8220ea7a4cf 100644 --- a/homeassistant/components/water_heater/triggers.yaml +++ b/homeassistant/components/water_heater/triggers.yaml @@ -7,12 +7,13 @@ required: true default: any 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 066fbc530af..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,14 +12,19 @@ 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 -from .coordinator import WaterFurnaceCoordinator +from .coordinator import ( + WaterFurnaceCoordinator, + WaterFurnaceDeviceData, + WaterFurnaceEnergyCoordinator, +) _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( { @@ -34,7 +37,7 @@ CONFIG_SCHEMA = vol.Schema( }, extra=vol.ALLOW_EXTRA, ) -type WaterFurnaceConfigEntry = ConfigEntry[dict[str, WaterFurnaceCoordinator]] +type WaterFurnaceConfigEntry = ConfigEntry[dict[str, WaterFurnaceDeviceData]] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -95,7 +98,7 @@ async def _async_setup_coordinator( password: str, device_index: int, entry: WaterFurnaceConfigEntry, -) -> tuple[str, WaterFurnaceCoordinator]: +) -> tuple[str, WaterFurnaceDeviceData]: """Set up a coordinator for a device.""" device_client = WaterFurnace(username, password, device=device_index) @@ -107,7 +110,21 @@ async def _async_setup_coordinator( raise ConfigEntryNotReady( f"Invalid GWID for device at index {device_index}: {device_client.gwid}" ) - return device_client.gwid, coordinator + + energy_coordinator = WaterFurnaceEnergyCoordinator( + hass, device_client, entry, device_client.gwid + ) + + # 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 + ) async def async_setup_entry( @@ -126,10 +143,12 @@ async def async_setup_entry( "Authentication failed. Please update your credentials." ) from err + device_count = len(client.devices) if client.devices else 0 + results = await asyncio.gather( *[ _async_setup_coordinator(hass, username, password, index, entry) - for index in range(len(client.devices) if client.devices else 0) + for index in range(device_count) ] ) entry.runtime_data = dict(results) 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/const.py b/homeassistant/components/waterfurnace/const.py index 5f12739eb05..f2382045df8 100644 --- a/homeassistant/components/waterfurnace/const.py +++ b/homeassistant/components/waterfurnace/const.py @@ -6,3 +6,4 @@ from typing import Final DOMAIN: Final = "waterfurnace" INTEGRATION_TITLE: Final = "WaterFurnace" UPDATE_INTERVAL: Final = timedelta(seconds=10) +ENERGY_UPDATE_INTERVAL: Final = timedelta(hours=2) diff --git a/homeassistant/components/waterfurnace/coordinator.py b/homeassistant/components/waterfurnace/coordinator.py index 66816763232..ed87eb788a1 100644 --- a/homeassistant/components/waterfurnace/coordinator.py +++ b/homeassistant/components/waterfurnace/coordinator.py @@ -1,20 +1,61 @@ """Data update coordinator for WaterFurnace.""" +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, WFException, WFGateway, WFReading +from waterfurnace.waterfurnace import ( + WaterFurnace, + WFCredentialError, + WFError, + WFException, + WFGateway, + WFNoDataError, + WFReading, +) -from homeassistant.core import HomeAssistant +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.models import StatisticMeanType +from homeassistant.components.recorder.models.statistics import ( + StatisticData, + StatisticMetaData, +) +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, +) +from homeassistant.const import UnitOfEnergy +from homeassistant.core import 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 -from .const import UPDATE_INTERVAL +from .const import DOMAIN, ENERGY_UPDATE_INTERVAL, UPDATE_INTERVAL if TYPE_CHECKING: from . import WaterFurnaceConfigEntry _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: + """Container for per-device coordinators.""" + + realtime: WaterFurnaceCoordinator + energy: WaterFurnaceEnergyCoordinator + class WaterFurnaceCoordinator(DataUpdateCoordinator[WFReading]): """WaterFurnace data update coordinator. @@ -48,9 +89,320 @@ 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) except WFException as err: raise UpdateFailed(str(err)) from err + + +class WaterFurnaceEnergyCoordinator(DataUpdateCoordinator[None]): + """WaterFurnace energy data coordinator. + + Periodically fetches energy data and inserts external statistics + for the Energy Dashboard. + """ + + config_entry: WaterFurnaceConfigEntry + + def __init__( + self, + hass: HomeAssistant, + client: WaterFurnace, + config_entry: WaterFurnaceConfigEntry, + gwid: str, + ) -> None: + """Initialize the energy coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"WaterFurnace Energy {gwid}", + update_interval=ENERGY_UPDATE_INTERVAL, + config_entry=config_entry, + ) + 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, + name=f"WaterFurnace Energy {gwid}", + source=DOMAIN, + statistic_id=self.statistic_id, + unit_class=EnergyConverter.UNIT_CLASS, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ) + + @callback + def _dummy_listener() -> None: + pass + + # Ensure periodic polling even without entity listeners, + # since this coordinator only inserts external statistics. + self.async_add_listener(_dummy_listener) + + async def _async_get_last_stat(self) -> tuple[float, float] | None: + """Get the last recorded statistic timestamp and sum. + + Returns (timestamp, sum) or None if no statistics exist. + """ + last_stat = await get_instance(self.hass).async_add_executor_job( + get_last_statistics, self.hass, 1, self.statistic_id, True, {"sum"} + ) + if not last_stat: + return None + entry = last_stat[self.statistic_id][0] + 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. + + On auth failure, re-login once and retry the request. + """ + try: + 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 + if reading.total_power is not None + ] + + @staticmethod + def _build_statistics( + readings: list[tuple[datetime, float]], + last_ts: float, + last_sum: float, + current_hour_ts: float | None = None, + ) -> list[StatisticData]: + """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 + for timestamp, kwh in sorted(readings, key=lambda x: x[0]): + ts = timestamp.timestamp() + if ts <= last_ts: + continue + 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: + continue + seen_hours.add(hour_ts) + running_sum += kwh + statistics.append( + StatisticData( + start=timestamp.replace(minute=0, second=0, microsecond=0), + state=kwh, + sum=running_sum, + ) + ) + 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. + + 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: + # 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 any missing hours + _LOGGER.debug("Last stat: ts=%s, sum=%s", last_dt.isoformat(), last_sum) + local_tz = dt_util.DEFAULT_TIME_ZONE + 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: + readings = await self.hass.async_add_executor_job( + self._fetch_energy_data, start_date, end_date + ) + except WFNoDataError: + _LOGGER.debug("No energy data available for %s to %s", start_date, end_date) + return + except WFException as err: + raise UpdateFailed(str(err)) from err + + if not readings: + _LOGGER.debug("No readings returned for %s to %s", start_date, end_date) + return + + _LOGGER.debug("Fetched %s readings", len(readings)) + + # 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)) + + if statistics: + async_add_external_statistics( + self.hass, self._statistic_metadata, 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 bcdfff1ca99..f94c80c56f6 100644 --- a/homeassistant/components/waterfurnace/manifest.json +++ b/homeassistant/components/waterfurnace/manifest.json @@ -1,6 +1,7 @@ { "domain": "waterfurnace", "name": "WaterFurnace", + "after_dependencies": ["recorder"], "codeowners": ["@sdague", "@masterkoppa"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waterfurnace", @@ -8,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 519ea0acea1..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( @@ -156,18 +153,17 @@ async def async_setup_entry( ) -> None: """Set up Waterfurnace sensors from a config entry.""" async_add_entities( - WaterFurnaceSensor(coordinator, description) - for coordinator in config_entry.runtime_data.values() + WaterFurnaceSensor(device_data.realtime, description) + for device_data in config_entry.runtime_data.values() for description in SENSORS ) -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/watts/__init__.py b/homeassistant/components/watts/__init__.py index 0d4f08741e0..92cc21e862d 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 diff --git a/homeassistant/components/watts/climate.py b/homeassistant/components/watts/climate.py index d30e21b5275..0e80bebfda6 100644 --- a/homeassistant/components/watts/climate.py +++ b/homeassistant/components/watts/climate.py @@ -1,7 +1,5 @@ """Climate platform for Watts Vision integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index c24853eb52c..d85701bf645 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.""" 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/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..83b7262bae5 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,6 +165,7 @@ 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]} @@ -218,4 +226,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..c7738a9e220 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,6 +93,9 @@ OPTIONS_SCHEMA = vol.Schema( enable_second=False, ) ), + vol.Optional(CONF_BASE_COORDINATES): LocationSelector( + LocationSelectorConfig(radius=False) + ), } ) @@ -114,18 +118,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 +161,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..1a6d4f3ab0c 100644 --- a/homeassistant/components/waze_travel_time/coordinator.py +++ b/homeassistant/components/waze_travel_time/coordinator.py @@ -20,6 +20,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 +31,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 +55,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 +80,7 @@ async def async_get_travel_times( real_time=realtime, alternatives=3, time_delta=time_delta, + base_coords=base_coordinates, ) if len(routes) < 1: @@ -192,7 +196,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 +215,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 +232,7 @@ class WazeTravelTimeCoordinator(DataUpdateCoordinator[WazeTravelTimeData]): incl_filter, excl_filter, time_delta, + base_coordinates, ) if len(routes) < 1: travel_data = WazeTravelTimeData( @@ -249,6 +257,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..ad24aac5ed2 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 @@ -1223,7 +1221,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..137e7edf51b 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.""" 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..1b200f439b1 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, 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..be4fd2ee9a2 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,10 +18,10 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfLength, + UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, @@ -34,9 +32,12 @@ 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 = { @@ -235,42 +236,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 +356,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) 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..0534c420785 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 = { 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..2421873cd9e 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 diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py index cb1607685b2..8b1ffe885bf 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 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..d5c260535f6 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=10), ), ) diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index 92ef59db908..1b8defbb9fe 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,26 @@ 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 +106,6 @@ def async_generate_id() -> str: @callback -@bind_hass def async_generate_url( hass: HomeAssistant, webhook_id: str, @@ -117,18 +136,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 +172,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 +180,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 +203,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: @@ -240,14 +255,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 +293,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..5c9634996c6 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 @@ -69,7 +65,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..3268d6bc9b1 100644 --- a/homeassistant/components/webostv/device_trigger.py +++ b/homeassistant/components/webostv/device_trigger.py @@ -1,7 +1,5 @@ """Provides device automations for control of LG webOS TV.""" -from __future__ import annotations - import voluptuous as vol from homeassistant.components.device_automation import ( 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..3804983f324 100644 --- a/homeassistant/components/webostv/helpers.py +++ b/homeassistant/components/webostv/helpers.py @@ -1,7 +1,5 @@ """Helper functions for LG webOS TV.""" -from __future__ import annotations - import logging from aiowebostv import WebOsClient, WebOsTvState diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index cb2059be2f4..fa48c41f2d3 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 diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index a2e9753c172..2cb61eaa585 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 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/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..430d2577f39 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 ) @@ -220,6 +235,7 @@ def _async_get_automation_components_for_target( hass, target_helpers.TargetSelection(target_selection), expand_group=expand_group, + primary_entities_only=False, ) _LOGGER.debug("Extracted entities for lookup: %s", extracted) @@ -232,30 +248,39 @@ 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..ce92ee2ac57 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -1,7 +1,5 @@ """Commands part of Websocket API.""" -from __future__ import annotations - from collections.abc import Callable from functools import lru_cache, partial import json @@ -865,6 +863,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 +873,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 = { @@ -1024,10 +1026,13 @@ async def handle_test_condition( # 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"))} - ) + condition = await async_condition_from_config(hass, config) + try: + connection.send_result( + msg["id"], {"result": condition.async_check(variables=msg.get("variables"))} + ) + finally: + condition.async_unload() @decorators.websocket_command( @@ -1069,6 +1074,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..86a7cf54fef 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 diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 4d5a53907b2..2267e333f75 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 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/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..38f0e6e99b7 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, 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..a1b79ae0ffd 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 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..97802ba698f 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) @@ -158,11 +155,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..1f273addc19 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,8 +21,10 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +type WiffiConfigEntry = ConfigEntry[WiffiIntegrationApi] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: WiffiConfigEntry) -> bool: """Set up wiffi from a config entry, config_entry contains data from config entry database.""" # create api object @@ -35,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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..7fe27c72ccc 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 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..1cd3840a331 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.2"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/homeassistant/components/wiim/media_player.py b/homeassistant/components/wiim/media_player.py index dbb8d8edb8b..faefbe4f6a3 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 @@ -66,7 +64,6 @@ SUPPORT_WIIM_BASE = ( | MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.SELECT_SOURCE - | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.SEEK ) 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/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 b0b4d3f4aef..30ff7ce6734 100644 --- a/homeassistant/components/window/strings.json +++ b/homeassistant/components/window/strings.json @@ -1,17 +1,19 @@ { "common": { - "condition_behavior_description": "How the state should match on the targeted windows.", - "condition_behavior_name": "Behavior", - "trigger_behavior_description": "The behavior of the targeted windows to trigger on.", - "trigger_behavior_name": "Behavior" + "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_closed": { "description": "Tests if one or more windows are closed.", "fields": { "behavior": { - "description": "[%key:component::window::common::condition_behavior_description%]", "name": "[%key:component::window::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::window::common::condition_for_name%]" } }, "name": "Window is closed" @@ -20,36 +22,25 @@ "description": "Tests if one or more windows are open.", "fields": { "behavior": { - "description": "[%key:component::window::common::condition_behavior_description%]", "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": { "description": "Triggers after one or more windows close.", "fields": { "behavior": { - "description": "[%key:component::window::common::trigger_behavior_description%]", "name": "[%key:component::window::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::window::common::trigger_for_name%]" } }, "name": "Window closed" @@ -58,8 +49,10 @@ "description": "Triggers after one or more windows open.", "fields": { "behavior": { - "description": "[%key:component::window::common::trigger_behavior_description%]", "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..3663e0f6125 100644 --- a/homeassistant/components/window/triggers.yaml +++ b/homeassistant/components/window/triggers.yaml @@ -3,12 +3,13 @@ required: true default: any 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..bb1e36b2cdd 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 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..c704c574429 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 diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index f66df15f6b4..eae5657589a 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -1,7 +1,5 @@ """WiZ Platform integration.""" -from __future__ import annotations - import logging from typing import Any diff --git a/homeassistant/components/wiz/binary_sensor.py b/homeassistant/components/wiz/binary_sensor.py index 9f5e548d552..8f1c5ff53a2 100644 --- a/homeassistant/components/wiz/binary_sensor.py +++ b/homeassistant/components/wiz/binary_sensor.py @@ -1,7 +1,5 @@ """WiZ integration binary sensor platform.""" -from __future__ import annotations - from collections.abc import Callable from pywizlight.bulb import PIR_SOURCE diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index a676c77688d..623369c79b0 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 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/strings.json b/homeassistant/components/wiz/strings.json index 5569cb422d4..b3bd6120fe0 100644 --- a/homeassistant/components/wiz/strings.json +++ b/homeassistant/components/wiz/strings.json @@ -6,10 +6,10 @@ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" }, "error": { - "bulb_time_out": "Can not connect to the bulb. Maybe the bulb is offline or a wrong IP was entered. Please turn on the light and try again!", + "bulb_time_out": "Cannot connect to the bulb. Maybe the bulb is offline or a wrong IP was entered. Please turn on the light and try again!", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_ip": "Not a valid IP address.", - "no_wiz_light": "The bulb cannot be connected via WiZ Platform integration.", + "no_wiz_light": "The bulb cannot be connected via WiZ integration.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "flow_title": "{name} ({host})", @@ -26,7 +26,7 @@ "data": { "host": "[%key:common::config_flow::data::ip%]" }, - "description": "If you leave the IP Address empty, discovery will be used to find devices." + "description": "If you leave the IP address empty, discovery will be used to find devices." } } }, 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..785e37ee6b6 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 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/coordinator.py b/homeassistant/components/wled/coordinator.py index eb876985c57..2ab034f421c 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 ( 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..c82884ebace 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 @@ -188,12 +186,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]: diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index b14c5df25ef..37352376050 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.22.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index a91d83a3ee9..3964a0c7dd0 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 @@ -54,7 +52,7 @@ NUMBERS = [ 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, diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index 759f8fdc3db..70eb8e5a901 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 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..6b5b7614780 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 @@ -200,11 +251,18 @@ def async_update_segments( 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: 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..dd68b8cd6a8 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 ( 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/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..0ac1577a3b2 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: 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..d143273a6aa 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: 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..51a818c2dbb 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): @@ -133,17 +130,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) ] diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index cbcf12cf31c..62a9f317cc6 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 diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 69bdd315609..f10f48d5196 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 diff --git a/homeassistant/components/workday/calendar.py b/homeassistant/components/workday/calendar.py index e631ebb6e6a..92b0b753bc7 100644 --- a/homeassistant/components/workday/calendar.py +++ b/homeassistant/components/workday/calendar.py @@ -1,7 +1,5 @@ """Workday Calendar.""" -from __future__ import annotations - from datetime import date, datetime, timedelta from holidays import HolidayBase diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index f3b139b27c0..afced85c7b0 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 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 ff67d631e82..e061de1eba8 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.84"] + "requirements": ["holidays==0.96"] } diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py index e0fa4c766c5..a340eaf9931 100644 --- a/homeassistant/components/workday/repairs.py +++ b/homeassistant/components/workday/repairs.py @@ -1,7 +1,5 @@ """Repairs platform for the Workday integration.""" -from __future__ import annotations - from typing import Any, cast from holidays import list_supported_countries 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..76d38413009 100644 --- a/homeassistant/components/worldclock/config_flow.py +++ b/homeassistant/components/worldclock/config_flow.py @@ -1,7 +1,5 @@ """Config flow for World clock.""" -from __future__ import annotations - from collections.abc import Mapping from typing import Any, cast import zoneinfo diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py index 9b52993919c..a0a8bbc9bc6 100644 --- a/homeassistant/components/worldclock/sensor.py +++ b/homeassistant/components/worldclock/sensor.py @@ -1,7 +1,5 @@ """Support for showing the time in a different time zone.""" -from __future__ import annotations - from datetime import tzinfo from homeassistant.components.sensor import SensorEntity 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..d512d66d83e 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) @@ -181,7 +178,19 @@ 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_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["tts_output"]): @@ -190,13 +199,6 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): # 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 - 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 elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: self.config_entry.async_create_background_task( self.hass, @@ -321,7 +323,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 +444,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 +457,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) @@ -699,10 +710,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 +739,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 +774,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 +808,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 +854,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..2b1edc43afa 100644 --- a/homeassistant/components/wyoming/conversation.py +++ b/homeassistant/components/wyoming/conversation.py @@ -10,7 +10,6 @@ from wyoming.info import HandleProgram, IntentProgram from wyoming.intent import Intent, 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 @@ -20,18 +19,18 @@ 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 +47,7 @@ class WyomingConversationEntity( def __init__( self, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, service: WyomingService, ) -> None: """Set up provider.""" 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/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..7677fa2ec11 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 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/coordinator.py b/homeassistant/components/xbox/coordinator.py index fa0c3eec595..4f71f90293e 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 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..2bcb2e96613 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 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..d14fff0a6e1 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 diff --git a/homeassistant/components/xeoma/camera.py b/homeassistant/components/xeoma/camera.py index 0c19e126fa7..5a61ffed462 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 diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py index cb8d5f39dec..72764c6bf52 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 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..931de28dbae 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=hass-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/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..1c90eaed2c1 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__) @@ -87,12 +85,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 +171,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..b1f5ba85348 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 diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py index 8956e207253..8c09d6ed34c 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, diff --git a/homeassistant/components/xiaomi_ble/config_flow.py b/homeassistant/components/xiaomi_ble/config_flow.py index c293d7832d0..369424a439f 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 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 8dfbe0a1c74..156a9f9e6c4 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.0"] + "requirements": ["xiaomi-ble==1.10.1"] } 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..2e6a4e3616e 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 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..c3dd5737151 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 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..1c89af75e1b 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 diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index ab11572006e..92da0cfc6e2 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 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..b6caa587231 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 diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 70deeb141c0..d83b33939b7 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 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..9aeab3a2aaf 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 diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index 5ff31c3bb9e..c42a92c0ecf 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 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/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..ffdbc63f33f 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 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..2d2ee1b8423 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 diff --git a/homeassistant/components/yale/sensor.py b/homeassistant/components/yale/sensor.py index 91ecbea704d..d6d7946524b 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 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..5963c5499f3 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 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..26231a8d58e 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, diff --git a/homeassistant/components/yalexs_ble/binary_sensor.py b/homeassistant/components/yalexs_ble/binary_sensor.py index dc924486df2..b7625a906a3 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 ( 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/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..0765fad3134 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 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..8722e7ac0ef 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 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..cf13af76de4 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 diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 33fb32fffa1..a617ef0b9d0 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 @@ -614,11 +611,14 @@ 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 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..24df2a41ff7 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,7 +15,7 @@ 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] @@ -29,17 +25,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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..df25ae39078 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 @dataclass(kw_only=True, frozen=True) @@ -77,11 +73,11 @@ 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 entities: list[BinarySensorEntity] = [ YardianBinarySensor(coordinator, description) diff --git a/homeassistant/components/yardian/config_flow.py b/homeassistant/components/yardian/config_flow.py index 0a947537db0..632ebc52e8e 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 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/sensor.py b/homeassistant/components/yardian/sensor.py index 3be0ddee76b..428a2f5e05d 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,7 +9,6 @@ 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 @@ -19,8 +16,7 @@ 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 # 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,11 +88,11 @@ 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 diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py index ba98fa2aaaa..67b53bd2fc3 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 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, 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..5167c3f3010 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() @@ -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..9821ec50ac8 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) 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..4162045bb91 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,24 +55,20 @@ 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") @@ -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..ad54fed1f79 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: 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..da8a0f3e2c9 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" @@ -65,11 +61,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 +90,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..41ce171439b 100644 --- a/homeassistant/components/yolink/services.py +++ b/homeassistant/components/yolink/services.py @@ -45,7 +45,7 @@ def async_setup_services(hass: HomeAssistant) -> None: 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/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..e410ee87356 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__) diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py index 476e5bb4022..232eb381f18 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, @@ -29,14 +27,19 @@ from .const import ( 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 diff --git a/homeassistant/components/youtube/diagnostics.py b/homeassistant/components/youtube/diagnostics.py index 9a898b7e2de..729e3747e36 100644 --- a/homeassistant/components/youtube/diagnostics.py +++ b/homeassistant/components/youtube/diagnostics.py @@ -1,23 +1,18 @@ """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) 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..f2b90423211 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, @@ -26,9 +22,8 @@ from .const import ( ATTR_TITLE, ATTR_TOTAL_VIEWS, ATTR_VIDEO_ID, - COORDINATOR, - DOMAIN, ) +from .coordinator import YouTubeConfigEntry from .entity import YouTubeChannelEntity @@ -79,13 +74,11 @@ SENSOR_TYPES = [ 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/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..f0f3e393aa9 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 @@ -24,12 +22,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__) @@ -53,9 +48,6 @@ 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[ diff --git a/homeassistant/components/zeroconf/repairs.py b/homeassistant/components/zeroconf/repairs.py index 2af53ff4625..3054304f48d 100644 --- a/homeassistant/components/zeroconf/repairs.py +++ b/homeassistant/components/zeroconf/repairs.py @@ -1,7 +1,5 @@ """Repairs for the zeroconf integration.""" -from __future__ import annotations - from homeassistant import data_entry_flow from homeassistant.components.homeassistant import ( DOMAIN as HOMEASSISTANT_DOMAIN, 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..a1a2d829f43 100644 --- a/homeassistant/components/zerproc/__init__.py +++ b/homeassistant/components/zerproc/__init__.py @@ -1,4 +1,5 @@ """Zerproc lights integration.""" +# pylint: disable=hass-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..d0c751820fc 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=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from datetime import timedelta import logging 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..89a1e176a59 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 @@ -16,13 +14,15 @@ 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, diff --git a/homeassistant/components/zeversolar/diagnostics.py b/homeassistant/components/zeversolar/diagnostics.py index 6e6ed262f51..b1cbf3e8a4b 100644 --- a/homeassistant/components/zeversolar/diagnostics.py +++ b/homeassistant/components/zeversolar/diagnostics.py @@ -4,21 +4,18 @@ 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.""" - coordinator: ZeversolarCoordinator = hass.data[DOMAIN][config_entry.entry_id] - data: ZeverSolarData = coordinator.data + data: ZeverSolarData = config_entry.runtime_data.data payload: dict[str, Any] = { "wifi_enabled": data.wifi_enabled, @@ -40,10 +37,10 @@ async def async_get_config_entry_diagnostics( 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] + coordinator = entry.runtime_data updateInterval = ( None diff --git a/homeassistant/components/zeversolar/entity.py b/homeassistant/components/zeversolar/entity.py index 3e085d952ca..aa6a5440565 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 diff --git a/homeassistant/components/zeversolar/sensor.py b/homeassistant/components/zeversolar/sensor.py index 330e5bb72d8..a900a191ad1 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.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 @@ -53,11 +49,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/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index ff61ce07d23..0e9ac369ff9 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 ( 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..2c5291a5c91 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 diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index a3f60420a38..ce3fc93054c 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 diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 54034fc6b13..2a7aa4fa466 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -1,7 +1,5 @@ """Config flow for ZHA.""" -from __future__ import annotations - from abc import abstractmethod import asyncio import collections @@ -9,7 +7,6 @@ from contextlib import suppress from enum import StrEnum import json import logging -import os from typing import Any import voluptuous as vol @@ -20,13 +17,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 USBDevice, scan_serial_ports from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_ZEROCONF, @@ -42,8 +35,12 @@ 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 @@ -60,7 +57,6 @@ 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 @@ -103,12 +99,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,69 +124,12 @@ def _format_backup_choice( return f"{dt_util.as_local(backup.backup_time).strftime('%c')} ({identifier})" -async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice]: - """List all serial ports, including the Yellow radio and the multi-PAN addon.""" - ports: list[USBDevice] = [] - 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 = USBDevice( - device="/dev/ttyAMA1", - vid="ffff", # This is technically not a USB device - pid="ffff", - 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 = USBDevice( - device=silabs_multiprotocol_addon.get_zigbee_socket(), - vid="ffff", # This is technically not a USB device - pid="ffff", - 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.""" @@ -254,33 +187,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 = [ - f"{resolved_paths[p.device]} - {p.description}{', s/n: ' + p.serial_number if p.serial_number else ''}" - + (f" - {p.manufacturer}" if p.manufacturer else "") - 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: @@ -289,34 +198,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) @@ -331,7 +231,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: @@ -354,7 +254,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( { @@ -964,7 +863,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: @@ -990,15 +893,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( @@ -1057,7 +962,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( @@ -1090,7 +994,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 @@ -1111,7 +1014,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", @@ -1126,10 +1028,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") @@ -1145,7 +1044,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/cover.py b/homeassistant/components/zha/cover.py index 213d5d11150..0d962899584 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 diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 92c4af0ff33..b04a8254a4e 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -1,7 +1,5 @@ """Provides device actions for ZHA devices.""" -from __future__ import annotations - from typing import Any import voluptuous as vol 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..a133b8f42a7 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,6 +202,7 @@ 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) diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 81206f8819e..61315f91a53 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 diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 436e95f8ef9..bc66ce489ac 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -1,7 +1,5 @@ """Helper functions for the ZHA integration.""" -from __future__ import annotations - import asyncio import collections from collections.abc import Awaitable, Callable, Coroutine, Mapping @@ -77,6 +75,8 @@ from zha.zigbee.cluster_handlers import ClusterBindEvent, ClusterConfigureReport from zha.zigbee.device import ( ClusterHandlerConfigurationComplete, Device, + DeviceEntityAddedEvent, + DeviceEntityRemovedEvent, DeviceFirmwareInfoUpdatedEvent, ZHAEvent, ) @@ -206,6 +206,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" @@ -495,6 +496,41 @@ class ZHADeviceProxy(EventBase): }, ) + @callback + def handle_zha_device_entity_added_event( + self, event: DeviceEntityAddedEvent + ) -> None: + """Handle a new entity being added to a device at runtime.""" + 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_device_entity_removed_event( + self, event: DeviceEntityRemovedEvent + ) -> None: + """Handle an entity being removed from a device at runtime.""" + 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): """Describes an entity reference.""" @@ -814,13 +850,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.""" 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..4d1c0cd9c71 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 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 31f0d0d9e83..80d044221df 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.0", "serialx==0.6.2"], + "requirements": ["zha==1.3.0"], "usb": [ { "description": "*2652*", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 4df9c7611bc..eba532010d5 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 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..89c253bbe34 100644 --- a/homeassistant/components/zha/repairs/network_settings_inconsistent.py +++ b/homeassistant/components/zha/repairs/network_settings_inconsistent.py @@ -1,7 +1,5 @@ """ZHA repair for inconsistent network settings.""" -from __future__ import annotations - import logging from typing import Any 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..72dbad20137 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 diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 73d773b1640..89d9d2ac584 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 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..aa083b5e227 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -1,7 +1,5 @@ """Support for ZHA sirens.""" -from __future__ import annotations - import functools from typing import Any diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index dc150e2407d..46f4bb30998 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 diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py index 867e4ff2dd3..caa77f5be24 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 diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 431a567e408..8a13904b156 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 @@ -47,7 +45,6 @@ from zha.application.helpers import ( qr_to_install_code, ) from zha.zigbee.cluster_handlers.const import CLUSTER_HANDLER_IAS_WD -from zha.zigbee.device import Device from zha.zigbee.group import GroupMemberReference import zigpy.backups from zigpy.config import CONF_DEVICE @@ -635,10 +632,17 @@ 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.""" @@ -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 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..b4fb6dd916a 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 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/quality_scale.yaml b/homeassistant/components/zimi/quality_scale.yaml index 8b8b85c71f4..a031bfc48aa 100644 --- a/homeassistant/components/zimi/quality_scale.yaml +++ b/homeassistant/components/zimi/quality_scale.yaml @@ -96,5 +96,4 @@ rules: status: exempt comment: | This integration does not use web sessions. - strict-typing: - status: todo + strict-typing: todo 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/binary_sensor.py b/homeassistant/components/zinvolt/binary_sensor.py index b34fada6ee4..52e44ec7c77 100644 --- a/homeassistant/components/zinvolt/binary_sensor.py +++ b/homeassistant/components/zinvolt/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import ZinvoltConfigEntry, ZinvoltData, ZinvoltDeviceCoordinator -from .entity import ZinvoltEntity +from .entity import ZinvoltEntity, ZinvoltUnitEntity POINT_ENTITIES = { "communication": BinarySensorDeviceClass.PROBLEM, @@ -57,9 +57,10 @@ async def async_setup_entry( for coordinator in entry.runtime_data.values() ] entities.extend( - ZinvoltPointBinarySensor(coordinator, point) + ZinvoltPointBinarySensor(coordinator, battery.serial_number, point) for coordinator in entry.runtime_data.values() - for point in coordinator.data.points + for battery in coordinator.battery_units.values() + for point in coordinator.data.batteries[battery.serial_number].points if point in POINT_ENTITIES ) async_add_entities(entities) @@ -88,25 +89,27 @@ class ZinvoltBatteryStateBinarySensor(ZinvoltEntity, BinarySensorEntity): return self.entity_description.is_on_fn(self.coordinator.data) -class ZinvoltPointBinarySensor(ZinvoltEntity, BinarySensorEntity): +class ZinvoltPointBinarySensor(ZinvoltUnitEntity, BinarySensorEntity): """Zinvolt battery state binary sensor.""" _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, coordinator: ZinvoltDeviceCoordinator, point: str) -> None: + def __init__( + self, coordinator: ZinvoltDeviceCoordinator, unit_serial_number: str, point: str + ) -> None: """Initialize the binary sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, unit_serial_number) self.point = point self._attr_translation_key = point self._attr_device_class = POINT_ENTITIES[point] - self._attr_unique_id = f"{coordinator.data.battery.serial_number}.{point}" + self._attr_unique_id = f"{self.serial_number}.{point}" @property def available(self) -> bool: """Return the availability of the binary sensor.""" - return super().available and self.point in self.coordinator.data.points + return super().available and self.point in self.battery.points @property def is_on(self) -> bool: """Return the state of the binary sensor.""" - return not self.coordinator.data.points[self.point] + return not self.battery.points[self.point] 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 862a4cf8718..c2471f162da 100644 --- a/homeassistant/components/zinvolt/coordinator.py +++ b/homeassistant/components/zinvolt/coordinator.py @@ -6,7 +6,7 @@ import logging from zinvolt import ZinvoltClient from zinvolt.exceptions import ZinvoltError -from zinvolt.models import Battery, BatteryState +from zinvolt.models import Battery, BatteryState, Unit, UnitType from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -24,6 +24,13 @@ class ZinvoltData: """Data for the Zinvolt integration.""" battery: BatteryState + batteries: dict[str, BatteryData] + + +@dataclass +class BatteryData: + """Data per battery unit.""" + sw_version: str model: str points: dict[str, bool] @@ -32,6 +39,8 @@ class ZinvoltData: class ZinvoltDeviceCoordinator(DataUpdateCoordinator[ZinvoltData]): """Class for Zinvolt devices.""" + battery_units: dict[str, Unit] + def __init__( self, hass: HomeAssistant, @@ -50,15 +59,30 @@ class ZinvoltDeviceCoordinator(DataUpdateCoordinator[ZinvoltData]): self.battery = battery self.client = client + async def _async_setup(self) -> None: + """Set up the Zinvolt integration.""" + try: + units = await self.client.get_units(self.battery.identifier) + except ZinvoltError as err: + raise UpdateFailed( + translation_key="update_failed", translation_domain=DOMAIN + ) from err + self.battery_units = { + unit.serial_number: unit for unit in units if unit.type is UnitType.BATTERY + } + async def _async_update_data(self) -> ZinvoltData: """Update data from Zinvolt.""" try: battery_state = await self.client.get_battery_status( self.battery.identifier ) - battery_unit = await self.client.get_battery_unit( - self.battery.identifier, self.battery.serial_number - ) + battery_units = { + unit_serial_number: await self.client.get_battery_unit( + self.battery.identifier, unit_serial_number + ) + for unit_serial_number in self.battery_units + } except ZinvoltError as err: raise UpdateFailed( translation_key="update_failed", @@ -66,7 +90,15 @@ class ZinvoltDeviceCoordinator(DataUpdateCoordinator[ZinvoltData]): ) from err return ZinvoltData( battery_state, - battery_unit.version.current_version, - battery_unit.battery_model, - {point.point.lower(): point.normal for point in battery_unit.points}, + { + serial_number: BatteryData( + battery_unit.version.current_version, + battery_unit.battery_model, + { + point.point.lower(): point.normal + for point in battery_unit.points + }, + ) + for serial_number, battery_unit in battery_units.items() + }, ) 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/entity.py b/homeassistant/components/zinvolt/entity.py index a9e9a2c89df..932e18fb095 100644 --- a/homeassistant/components/zinvolt/entity.py +++ b/homeassistant/components/zinvolt/entity.py @@ -1,10 +1,13 @@ """Base entity for Zinvolt integration.""" +from zinvolt.models import Unit + +from homeassistant.const import ATTR_VIA_DEVICE from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import ZinvoltDeviceCoordinator +from .coordinator import BatteryData, ZinvoltDeviceCoordinator class ZinvoltEntity(CoordinatorEntity[ZinvoltDeviceCoordinator]): @@ -20,6 +23,55 @@ class ZinvoltEntity(CoordinatorEntity[ZinvoltDeviceCoordinator]): manufacturer="Zinvolt", name=coordinator.battery.name, serial_number=coordinator.data.battery.serial_number, - model_id=coordinator.data.model, - sw_version=coordinator.data.sw_version, + ) + + +class ZinvoltUnitEntity(ZinvoltEntity): + """Base entity for Zinvolt units.""" + + def __init__( + self, coordinator: ZinvoltDeviceCoordinator, unit_serial_number: str + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.unit_serial_number = unit_serial_number + is_main_device = ( + list(coordinator.battery_units).index(self.unit_serial_number) == 0 + ) + self.serial_number = ( + coordinator.data.battery.serial_number + if is_main_device + else self.battery_unit.serial_number + ) + name = coordinator.battery.name if is_main_device else self.battery_unit.name + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.serial_number)}, + manufacturer="Zinvolt", + name=name, + serial_number=self.serial_number, + sw_version=self.battery_unit.version.current_version, + model_id=self.battery.model, + ) + if not is_main_device: + self._attr_device_info[ATTR_VIA_DEVICE] = ( + DOMAIN, + coordinator.data.battery.serial_number, + ) + + @property + def battery(self) -> BatteryData: + """Return the battery data.""" + return self.coordinator.data.batteries[self.unit_serial_number] + + @property + def battery_unit(self) -> Unit: + """Return the battery unit.""" + return self.coordinator.battery_units[self.unit_serial_number] + + @property + def available(self) -> bool: + """Return if the entity is available.""" + return ( + super().available + and self.unit_serial_number in self.coordinator.data.batteries ) diff --git a/homeassistant/components/zinvolt/manifest.json b/homeassistant/components/zinvolt/manifest.json index c0be07030c6..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.3.0"] + "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 b0d7a6ba8d1..b84ced1185c 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 @@ -46,7 +44,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 @@ -113,17 +110,21 @@ DATA_ZONE_STORAGE_COLLECTION: HassKey[ZoneStorageCollection] = HassKey(DOMAIN) DATA_ZONE_ENTITY_IDS: HassKey[list[str]] = HassKey(ZONE_ENTITY_IDS) -@bind_hass -def async_active_zone( +def async_in_zones( hass: HomeAssistant, latitude: float, longitude: float, radius: float = 0 -) -> State | None: - """Find the active zone for given latitude, longitude. +) -> tuple[State | None, list[str]]: + """Find zones which contain the given latitude and longitude. + + Returns a tuple of the closest active zone and a list of all zones which + contain the given latitude and longitude. The list of zones is sorted by + distance and then by radius so that the closest and smallest zone is first. This method must be run in the event loop. """ # Sort entity IDs so that we are deterministic if equal distance to 2 zones min_dist: float = sys.maxsize closest: State | None = None + 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, ()) @@ -133,10 +134,12 @@ def async_active_zone( not (zone := hass.states.get(entity_id)) # Skip unavailable zones or zone.state == STATE_UNAVAILABLE - # Skip passive zones - or (zone_attrs := zone.attributes).get(ATTR_PASSIVE) + ): + continue + zone_attrs = zone.attributes + if ( # Skip zones where we cannot calculate distance - or ( + ( zone_dist := distance( latitude, longitude, @@ -151,6 +154,12 @@ def async_active_zone( ): continue + zones.append((zone.entity_id, zone_dist, zone_radius)) + + # Skip passive zones + if zone_attrs.get(ATTR_PASSIVE): + continue + # If have a closest and its not closer than the closest skip it if closest and not ( zone_dist < min_dist @@ -166,7 +175,19 @@ def async_active_zone( min_dist = zone_dist closest = zone - return closest + # Sort by distance and then by radius so the closest and smallest zone is first. + zones.sort(key=lambda x: (x[1], x[2])) + return (closest, [itm[0] for itm in zones]) + + +def async_active_zone( + hass: HomeAssistant, latitude: float, longitude: float, radius: float = 0 +) -> State | None: + """Find the active zone for given latitude, longitude. + + This method must be run in the event loop. + """ + return async_in_zones(hass, latitude, longitude, radius)[0] @callback diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py index ee3f286c660..130648f5a27 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -1,7 +1,5 @@ """Offer zone automation rules.""" -from __future__ import annotations - from typing import Any, Unpack, cast import voluptuous as vol @@ -22,7 +20,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, ) @@ -117,44 +114,39 @@ 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 if_in_zone + return all_ok CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index 59e0f2f8821..0e3db732291 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -1,7 +1,5 @@ """Offer zone automation rules.""" -from __future__ import annotations - import logging import voluptuous as vol 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..e45f0609f21 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 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 2388cc085fa..73c24ccf2bd 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 @@ -1099,13 +1097,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 +1133,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: @@ -1753,16 +1750,21 @@ async def websocket_subscribe_rebuild_routes_progress( controller.on("rebuild routes done", partial(forward_event, "result")), ] + connection.send_result(msg[ID]) + if controller.rebuild_routes_progress: - connection.send_result( - msg[ID], - { - node.node_id: status - for node, status in controller.rebuild_routes_progress.items() - }, + connection.send_message( + websocket_api.event_message( + msg[ID], + { + "event": "rebuild routes progress", + "rebuild_routes_status": { + node.node_id: status + for node, status in controller.rebuild_routes_progress.items() + }, + }, + ) ) - else: - connection.send_result(msg[ID], None) @websocket_api.require_admin diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index c0df675a25d..c25a4e1dc61 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 @@ -17,18 +15,28 @@ from zwave_js_server.const.command_class.notification import ( SmokeAlarmNotificationEvent, ) from zwave_js_server.model.driver import Driver +from zwave_js_server.model.value import Value as ZwaveValue +from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.components.script import scripts_with_entity from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) +from homeassistant.helpers.start import async_at_started from .const import DOMAIN from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity @@ -37,6 +45,7 @@ from .helpers import ( is_opening_state_notification_value, ) from .models import ( + FirmwareVersionRange, NewZWaveDiscoverySchema, ValueType, ZwaveDiscoveryInfo, @@ -72,8 +81,7 @@ ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR = 5632 ACCESS_CONTROL_DOOR_STATE_OPEN_TILT = 5633 -# Numeric State values used by the "Opening state" notification variable. -# This is only needed temporarily until the legacy Access Control door state binary sensors are removed. +# Numeric State values used by the Opening state notification variable. class OpeningState(IntEnum): """Opening state values exposed by Access Control notifications.""" @@ -82,23 +90,23 @@ class OpeningState(IntEnum): TILTED = 2 -# parse_opening_state helpers for the DEPRECATED legacy Access Control binary sensors. -def _legacy_is_closed(opening_state: OpeningState) -> bool: +# parse_opening_state helpers. +def _opening_state_is_closed(opening_state: OpeningState) -> bool: """Return if Opening state represents closed.""" return opening_state is OpeningState.CLOSED -def _legacy_is_open(opening_state: OpeningState) -> bool: +def _opening_state_is_open(opening_state: OpeningState) -> bool: """Return if Opening state represents open.""" return opening_state is OpeningState.OPEN -def _legacy_is_open_or_tilted(opening_state: OpeningState) -> bool: +def _opening_state_is_open_or_tilted(opening_state: OpeningState) -> bool: """Return if Opening state represents open or tilted.""" return opening_state in (OpeningState.OPEN, OpeningState.TILTED) -def _legacy_is_tilted(opening_state: OpeningState) -> bool: +def _opening_state_is_tilted(opening_state: OpeningState) -> bool: """Return if Opening state represents tilted.""" return opening_state is OpeningState.TILTED @@ -127,12 +135,51 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription): @dataclass(frozen=True, kw_only=True) class OpeningStateZWaveJSEntityDescription(BinarySensorEntityDescription): - """Describe a legacy Access Control binary sensor that derives state from Opening state.""" + """Describe an Access Control binary sensor that derives state from Opening state.""" state_key: int parse_opening_state: Callable[[OpeningState], bool] +@dataclass(frozen=True, kw_only=True) +class LegacyDoorStateRepairDescription: + """Describe how a legacy door state entity should be migrated.""" + + issue_translation_key: str + replacement_state_key: OpeningState + + +LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS: dict[str, LegacyDoorStateRepairDescription] = { + "legacy_access_control_door_state_simple_open": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_open_state", + replacement_state_key=OpeningState.OPEN, + ), + "legacy_access_control_door_state_open": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_open_state", + replacement_state_key=OpeningState.OPEN, + ), + "legacy_access_control_door_state_open_regular": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_open_state", + replacement_state_key=OpeningState.OPEN, + ), + "legacy_access_control_door_state_open_tilt": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_tilt_state", + replacement_state_key=OpeningState.TILTED, + ), + "legacy_access_control_door_tilt_state_tilted": LegacyDoorStateRepairDescription( + issue_translation_key="deprecated_legacy_door_tilt_state", + replacement_state_key=OpeningState.TILTED, + ), +} + +LEGACY_DOOR_STATE_REPAIR_ISSUE_KEYS = frozenset( + { + description.issue_translation_key + for description in LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS.values() + } +) + + # Mappings for Notification sensors # https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx # @@ -389,6 +436,9 @@ BOOLEAN_SENSOR_MAPPINGS: dict[tuple[int, int | str], BinarySensorEntityDescripti } +# This can likely be removed once the legacy notification binary sensor +# discovery path is gone and Opening state is handled only by the dedicated +# discovery schemas below. @callback def is_valid_notification_binary_sensor( info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo, @@ -396,13 +446,111 @@ def is_valid_notification_binary_sensor( """Return if the notification CC Value is valid as binary sensor.""" if not info.primary_value.metadata.states: return False - # Access Control - Opening state is exposed as a single enum sensor instead - # of fanning out one binary sensor per state. + # Opening state is handled by dedicated discovery schemas if is_opening_state_notification_value(info.primary_value): return False return len(info.primary_value.metadata.states) > 1 +@callback +def _async_delete_legacy_entity_repairs(hass: HomeAssistant, entity_id: str) -> None: + """Delete all stale legacy door state repair issues for an entity.""" + for issue_key in LEGACY_DOOR_STATE_REPAIR_ISSUE_KEYS: + async_delete_issue(hass, DOMAIN, f"{issue_key}.{entity_id}") + + +@callback +def _async_check_legacy_entity_repair( + hass: HomeAssistant, + driver: Driver, + entity: ZWaveLegacyDoorStateBinarySensor, +) -> None: + """Schedule a repair issue check once HA has fully started.""" + + @callback + def _async_do_check(hass: HomeAssistant) -> None: + """Create or delete a repair issue for a deprecated legacy door state entity.""" + ent_reg = er.async_get(hass) + if entity.unique_id is None: + return + entity_id = ent_reg.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, entity.unique_id + ) + if entity_id is None: + return + + repair_description = LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS.get( + entity.entity_description.key + ) + if repair_description is None: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + entity_entry = ent_reg.async_get(entity_id) + if entity_entry is None or entity_entry.disabled: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + entity_automations = automations_with_entity(hass, entity_id) + entity_scripts = scripts_with_entity(hass, entity_id) + if not entity_automations and not entity_scripts: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + opening_state_value = get_opening_state_notification_value( + entity.info.node, entity.info.primary_value.endpoint + ) + if opening_state_value is None: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + replacement_unique_id = ( + f"{driver.controller.home_id}.{opening_state_value.value_id}." + f"{repair_description.replacement_state_key}" + ) + replacement_entity_id = ent_reg.async_get_entity_id( + BINARY_SENSOR_DOMAIN, DOMAIN, replacement_unique_id + ) + if replacement_entity_id is None: + _async_delete_legacy_entity_repairs(hass, entity_id) + return + + items = [] + for domain, entity_ids in ( + ("automation", entity_automations), + ("script", entity_scripts), + ): + for eid in entity_ids: + item = ent_reg.async_get(eid) + if item: + items.append( + f"- [{item.name or item.original_name or eid}]" + f"(/config/{domain}/edit/{item.unique_id})" + ) + else: + items.append(f"- {eid}") + + async_create_issue( + hass, + DOMAIN, + f"{repair_description.issue_translation_key}.{entity_id}", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key=repair_description.issue_translation_key, + translation_placeholders={ + "entity_id": entity_id, + "entity_name": ( + entity_entry.name or entity_entry.original_name or entity_id + ), + "replacement_entity_id": replacement_entity_id, + "items": "\n".join(items), + }, + ) + + async_at_started(hass, _async_do_check) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ZwaveJSConfigEntry, @@ -442,13 +590,21 @@ async def async_setup_entry( and info.entity_class is ZWaveBooleanBinarySensor ): entities.append(ZWaveBooleanBinarySensor(config_entry, driver, info)) + elif ( + isinstance(info, NewZwaveDiscoveryInfo) + and info.entity_class is ZWaveOpeningStateBinarySensor + and isinstance( + info.entity_description, OpeningStateZWaveJSEntityDescription + ) + ): + entities.append(ZWaveOpeningStateBinarySensor(config_entry, driver, info)) elif ( isinstance(info, NewZwaveDiscoveryInfo) and info.entity_class is ZWaveLegacyDoorStateBinarySensor ): - entities.append( - ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info) - ) + entity = ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info) + entities.append(entity) + _async_check_legacy_entity_repair(hass, driver, entity) elif isinstance(info, NewZwaveDiscoveryInfo): pass # other entity classes are not migrated yet elif info.platform_hint == "notification": @@ -632,6 +788,69 @@ class ZWaveLegacyDoorStateBinarySensor(ZWaveBaseEntity, BinarySensorEntity): return None +class ZWaveOpeningStateBinarySensor(ZWaveBaseEntity, BinarySensorEntity): + """Representation of a binary sensor derived from Opening state.""" + + entity_description: OpeningStateZWaveJSEntityDescription + _known_states: set[str] + + def __init__( + self, + config_entry: ZwaveJSConfigEntry, + driver: Driver, + info: NewZwaveDiscoveryInfo, + ) -> None: + """Initialize an Opening state binary sensor entity.""" + super().__init__(config_entry, driver, info) + self._known_states = set(info.primary_value.metadata.states or ()) + self._attr_unique_id = ( + f"{self._attr_unique_id}.{self.entity_description.state_key}" + ) + + @callback + def should_rediscover_on_metadata_update(self) -> bool: + """Check if metadata states require adding the Tilt entity.""" + return ( + # Open and Tilt entities share the same underlying Opening state value. + # Only let the main Open entity trigger rediscovery when Tilt first + # appears so we can add the missing sibling without recreating the + # main entity and losing its registry customizations. + str(OpeningState.TILTED) not in self._known_states + and str(OpeningState.TILTED) + in set(self.info.primary_value.metadata.states or ()) + and self.entity_description.state_key == OpeningState.OPEN + ) + + async def _async_remove_and_rediscover(self, value: ZwaveValue) -> None: + """Trigger re-discovery while preserving the main Opening state entity.""" + assert self.device_entry is not None + controller_events = ( + self.config_entry.runtime_data.driver_events.controller_events + ) + + # Unlike the base implementation, keep this entity in place so its + # registry entry and user customizations survive metadata rediscovery. + controller_events.discovered_value_ids[self.device_entry.id].discard( + value.value_id + ) + node_events = controller_events.node_events + value_updates_disc_info = node_events.value_updates_disc_info[ + value.node.node_id + ] + node_events.async_on_value_added(value_updates_disc_info, value) + + @property + def is_on(self) -> bool | None: + """Return if the sensor is on or off.""" + value = self.info.primary_value.value + if value is None: + return None + try: + return self.entity_description.parse_opening_state(OpeningState(int(value))) + except TypeError, ValueError: + return None + + class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity): """Representation of a Z-Wave binary_sensor from a property.""" @@ -730,11 +949,54 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ ), entity_class=ZWaveNotificationBinarySensor, ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Opening state"}, + type={ValueType.NUMBER}, + any_available_states_keys={OpeningState.TILTED}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + # Also derive the main binary sensor from the same value ID + allow_multi=True, + entity_description=OpeningStateZWaveJSEntityDescription( + key="access_control_opening_state_tilted", + name="Tilt", + state_key=OpeningState.TILTED, + parse_opening_state=_opening_state_is_tilted, + ), + entity_class=ZWaveOpeningStateBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.NOTIFICATION}, + property={"Access Control"}, + property_key={"Opening state"}, + type={ValueType.NUMBER}, + any_available_states_keys={OpeningState.OPEN}, + any_available_cc_specific={ + (CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL) + }, + ), + entity_description=OpeningStateZWaveJSEntityDescription( + key="access_control_opening_state_open", + state_key=OpeningState.OPEN, + parse_opening_state=_opening_state_is_open_or_tilted, + device_class=BinarySensorDeviceClass.DOOR, + ), + entity_class=ZWaveOpeningStateBinarySensor, + ), # ------------------------------------------------------------------- # DEPRECATED legacy Access Control door/window binary sensors. # These schemas exist only for backwards compatibility with users who # already have these entities registered. New integrations should use - # the Opening state enum sensor instead. Do not add new schemas here. + # the dedicated Opening state binary sensors instead. Do not add new + # schemas here. # All schemas below use ZWaveLegacyDoorStateBinarySensor and are # disabled by default (entity_registry_enabled_default=False). # ------------------------------------------------------------------- @@ -758,7 +1020,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_simple_open", name="Window/door is open", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN, - parse_opening_state=_legacy_is_open_or_tilted, + parse_opening_state=_opening_state_is_open_or_tilted, device_class=BinarySensorDeviceClass.DOOR, entity_registry_enabled_default=False, ), @@ -784,7 +1046,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_simple_closed", name="Window/door is closed", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED, - parse_opening_state=_legacy_is_closed, + parse_opening_state=_opening_state_is_closed, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -809,7 +1071,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_open", name="Window/door is open", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN, - parse_opening_state=_legacy_is_open, + parse_opening_state=_opening_state_is_open, device_class=BinarySensorDeviceClass.DOOR, entity_registry_enabled_default=False, ), @@ -835,7 +1097,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_closed", name="Window/door is closed", state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED, - parse_opening_state=_legacy_is_closed, + parse_opening_state=_opening_state_is_closed, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -858,7 +1120,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_open_regular", name="Window/door is open in regular position", state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR, - parse_opening_state=_legacy_is_open, + parse_opening_state=_opening_state_is_open, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -881,7 +1143,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_state_open_tilt", name="Window/door is open in tilt position", state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_TILT, - parse_opening_state=_legacy_is_tilted, + parse_opening_state=_opening_state_is_tilted, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -904,7 +1166,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ key="legacy_access_control_door_tilt_state_tilted", name="Window/door is tilted", state_key=OpeningState.OPEN, - parse_opening_state=_legacy_is_tilted, + parse_opening_state=_opening_state_is_tilted, entity_registry_enabled_default=False, ), entity_class=ZWaveLegacyDoorStateBinarySensor, @@ -1083,6 +1345,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..923ba382d47 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 diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 648b0109e3c..a429a784fe7 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 diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index b22d1af3c56..e590aaf5cf3 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.""" 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..cab3842605a 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 diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 4f537968422..ed0ce0afb50 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 ( @@ -495,18 +493,42 @@ 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: + 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: + 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() 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..4a8fec4ff94 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 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..7ef8138b8a3 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 diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 9087ea8ba68..c7c30369039 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 diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index cb4db816c50..74b856efbe0 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -1,7 +1,5 @@ """Generic Z-Wave Entity Class.""" -from __future__ import annotations - from collections.abc import Sequence from dataclasses import dataclass from typing import Any 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..4a2be4c6cb4 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 diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 6ca88e48ac7..c223c1d5780 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 @@ -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/light.py b/homeassistant/components/zwave_js/light.py index a2e59e4e6b2..ceab6923e41 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 ( diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index 1bdb70bcaa3..2c5d8ba4970 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 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..2cd6a6246dd 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.70.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..56f09409722 100644 --- a/homeassistant/components/zwave_js/repairs.py +++ b/homeassistant/components/zwave_js/repairs.py @@ -1,7 +1,5 @@ """Repairs for Z-Wave JS.""" -from __future__ import annotations - from homeassistant import data_entry_flow from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow from homeassistant.core import HomeAssistant 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..fbe976deb69 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 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..b4bc72e3112 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 diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 524a5b6c548..f30ddbd2dda 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 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 dbaefc4f1cf..cc933386d13 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -303,6 +303,14 @@ } }, "issues": { + "deprecated_legacy_door_open_state": { + "description": "The binary sensor `{entity_id}` is deprecated because it has been replaced with the binary sensor `{replacement_entity_id}`.\n\nThe entity was found in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the binary sensor `{replacement_entity_id}` and disable the binary sensor `{entity_id}` and then restart Home Assistant, to fix this issue.\n\nNote that `{replacement_entity_id}` is on when the door or window is open or tilted.", + "title": "Deprecation: {entity_name}" + }, + "deprecated_legacy_door_tilt_state": { + "description": "The binary sensor `{entity_id}` is deprecated because it has been replaced with the binary sensor `{replacement_entity_id}`.\n\nThe entity was found in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the binary sensor `{replacement_entity_id}` and disable the binary sensor `{entity_id}` and then restart Home Assistant, to fix this issue.\n\nNote that `{replacement_entity_id}` is on only when the door or window is tilted.", + "title": "Deprecation: {entity_name}" + }, "device_config_file_changed": { "fix_flow": { "abort": { 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/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..c4ba16acb67 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 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..428485c4fcc 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 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..7388326bfda 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,38 @@ 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: + cmd = f"{command_id}?{'&'.join(f'{argId}={argVal}' for argId, argVal in command_args.items())}" 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 ab4c2d7d7b3..23c442673fb 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 ( @@ -316,11 +314,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: @@ -579,6 +577,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.""" @@ -625,6 +630,14 @@ class ConfigEntry[_DataT = Any]: ) return self._supported_subentry_types or {} + def get_subentries_of_type(self, subentry_type: str) -> list[ConfigSubentry]: + """Return subentries of a specified subentry type.""" + return [ + subentry + for subentry in self.subentries.values() + if subentry.subentry_type == subentry_type + ] + def clear_state_cache(self) -> None: """Clear cached properties that are included in as_json_fragment.""" self.__dict__.pop("as_json_fragment", None) @@ -690,6 +703,9 @@ 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 ( @@ -718,7 +734,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, @@ -734,7 +750,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" @@ -769,7 +785,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 @@ -777,7 +793,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, @@ -792,13 +808,13 @@ 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) + logger.debug("Full exception", exc_info=True) self.async_start_reauth(hass) except ConfigEntryNotReady as exc: message = str(exc) @@ -816,14 +832,14 @@ class ConfigEntry[_DataT = Any]: ) 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( @@ -846,7 +862,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, @@ -861,13 +877,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 ) @@ -1021,7 +1037,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: @@ -1064,7 +1080,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, @@ -1109,7 +1125,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 @@ -1130,7 +1146,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, @@ -1140,14 +1156,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 @@ -1210,7 +1226,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, @@ -1239,7 +1255,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, @@ -1590,6 +1606,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], @@ -1628,7 +1664,7 @@ class ConfigEntriesFlowManager( ) } ) - _LOGGER.debug( + entry.logger.debug( "Updating discovery keys for %s entry %s %s -> %s", entry.domain, unique_id, @@ -1638,6 +1674,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. @@ -1752,6 +1790,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( @@ -1861,7 +1903,7 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]): 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) + 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) @@ -1884,7 +1926,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" @@ -2274,7 +2316,7 @@ class ConfigEntries: try: await loader.async_get_integration(self.hass, entry.domain) except loader.IntegrationNotFound: - _LOGGER.info( + entry.logger.info( "Integration for ignored config entry %s not found. Creating repair issue", entry, ) @@ -2506,7 +2548,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" @@ -3051,6 +3093,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 @@ -3273,7 +3322,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 @@ -3294,6 +3346,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, @@ -3438,6 +3499,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" @@ -4038,7 +4106,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 34032df2a92..99301a6308a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,5 @@ """Constants used by Home Assistant components.""" -from __future__ import annotations - from enum import StrEnum from typing import TYPE_CHECKING, Final @@ -16,7 +14,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 -MINOR_VERSION: Final = 5 +MINOR_VERSION: Final = 6 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" @@ -523,6 +521,7 @@ class UnitOfEnergyDistance(StrEnum): class UnitOfElectricCurrent(StrEnum): """Electric current units.""" + MICROAMPERE = "μA" MILLIAMPERE = "mA" AMPERE = "A" @@ -590,6 +589,7 @@ class UnitOfLength(StrEnum): class UnitOfFrequency(StrEnum): """Frequency units.""" + MILLIHERTZ = "mHz" HERTZ = "Hz" KILOHERTZ = "kHz" MEGAHERTZ = "MHz" diff --git a/homeassistant/core.py b/homeassistant/core.py index 1c51a564129..8c12e2764ec 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 ( @@ -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,14 @@ 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 +605,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. diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index f7169c38b91..e6cdb106739 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 @@ -526,7 +524,7 @@ class _ComponentSet(set[str]): self._top_level_components.remove(value) return super().remove(value) - def discard(self, value: str) -> None: + def discard(self, value: object) -> None: """Remove a component from the store.""" raise NotImplementedError("_ComponentSet does not support discard, use remove") diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 5df715b03ca..7c3df336268 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 diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 8b1c9c49afe..e17012d1f48 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -1,7 +1,5 @@ """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 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/bluetooth.py b/homeassistant/generated/bluetooth.py index 8abd999eedf..5d05b6bbf5e 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]]]] = [ @@ -133,6 +131,11 @@ 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", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index bb6901c6460..9ddd5528fd3 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", @@ -142,6 +143,7 @@ FLOWS = { "deconz", "decora_wifi", "deluge", + "denon_rs232", "denonavr", "devialet", "devolo_home_control", @@ -165,11 +167,13 @@ FLOWS = { "dsmr", "dsmr_reader", "duckdns", + "duco", "dunehd", "duotecno", "dwd_weather_warnings", "dynalite", "eafm", + "earn_e_p1", "easyenergy", "ecobee", "ecoforest", @@ -206,6 +210,7 @@ FLOWS = { "esphome", "essent", "eufylife_ble", + "eurotronic_cometblue", "evil_genius_labs", "ezviz", "faa_delays", @@ -242,6 +247,7 @@ FLOWS = { "frontier_silicon", "fujitsu_fglair", "fully_kiosk", + "fumis", "fyta", "garages_amsterdam", "gardena_bluetooth", @@ -306,6 +312,7 @@ FLOWS = { "homewizard", "homeworks", "honeywell", + "honeywell_string_lights", "hr_energy_qube", "html5", "huawei_lte", @@ -366,6 +373,7 @@ FLOWS = { "keenetic_ndms2", "kegtron", "keymitt_ble", + "kiosker", "kmtronic", "knocki", "knx", @@ -438,6 +446,7 @@ FLOWS = { "mikrotik", "mill", "minecraft_server", + "mitsubishi_comfort", "mjpeg", "moat", "mobile_app", @@ -490,6 +499,7 @@ FLOWS = { "nobo_hub", "nordpool", "notion", + "novy_cooker_hood", "nrgkick", "ntfy", "nuheat", @@ -502,6 +512,7 @@ FLOWS = { "octoprint", "ohme", "ollama", + "omie", "omnilogic", "ondilo_ico", "onedrive", @@ -527,7 +538,6 @@ FLOWS = { "orvibo", "osoenergy", "otbr", - "otp", "ourgroceries", "overkiz", "overseerr", @@ -545,6 +555,7 @@ FLOWS = { "philips_js", "pi_hole", "picnic", + "picotts", "ping", "pjlink", "plaato", @@ -569,6 +580,7 @@ FLOWS = { "proxmoxve", "prusalink", "ps4", + "ptdevices", "pterodactyl", "pure_energie", "purpleair", @@ -717,6 +729,7 @@ FLOWS = { "technove", "tedee", "telegram_bot", + "teleinfo", "tellduslive", "teltonika", "tesla_fleet", @@ -763,6 +776,7 @@ FLOWS = { "ukraine_alarm", "unifi", "unifi_access", + "unifi_discovery", "unifiprotect", "upb", "upcloud", @@ -782,6 +796,7 @@ FLOWS = { "vesync", "vicare", "victron_ble", + "victron_gx", "victron_remote_monitoring", "vilfo", "vivotek", 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..57f5ace2c12 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*", @@ -321,6 +331,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "hunter*", "macaddress": "002674*", }, + { + "domain": "iaqualink", + "hostname": "iaqualink-*", + }, { "domain": "incomfort", "hostname": "rfgateway", @@ -436,6 +450,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*", @@ -1319,43 +1341,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*", }, { 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 e43bb5959e6..4277316a566 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -736,6 +736,12 @@ "config_flow": false, "iot_class": "local_polling" }, + "bega": { + "name": "BEGA", + "iot_standards": [ + "zigbee" + ] + }, "bge": { "name": "Baltimore Gas and Electric (BGE)", "integration_type": "virtual", @@ -1317,6 +1323,12 @@ "iot_class": "local_push", "name": "Denon AVR Network Receivers" }, + "denon_rs232": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Denon RS232" + }, "heos": { "integration_type": "hub", "config_flow": true, @@ -1515,6 +1527,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", @@ -1545,6 +1563,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", @@ -1913,6 +1937,12 @@ } } }, + "eurotronic_cometblue": { + "name": "Eurotronic Comet Blue", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "eve": { "name": "Eve", "iot_standards": [ @@ -2302,6 +2332,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", @@ -2939,6 +2975,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" } } }, @@ -2962,7 +3004,7 @@ }, "html5": { "name": "HTML5 Push Notifications", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_push", "single_config_entry": true @@ -3050,7 +3092,7 @@ "iot_class": "local_polling" }, "iaqualink": { - "name": "Jandy iAqualink", + "name": "Jandy iAquaLink", "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", @@ -3449,6 +3491,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", @@ -4255,6 +4303,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "mitsubishi_comfort": { + "name": "Mitsubishi Comfort", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "mjpeg": { "name": "MJPEG IP Camera", "integration_type": "hub", @@ -4717,6 +4771,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", @@ -4856,6 +4916,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", @@ -5066,12 +5133,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "otp": { - "name": "One-Time Password (OTP)", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" - }, "ourgroceries": { "name": "OurGroceries", "integration_type": "service", @@ -5238,8 +5299,8 @@ }, "picotts": { "name": "Pico TTS", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "local_push" }, "pilight": { @@ -5446,6 +5507,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", @@ -6079,6 +6146,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "sensereo": { + "name": "Sensereo", + "iot_standards": [ + "matter" + ] + }, "sensibo": { "name": "Sensibo", "integration_type": "hub", @@ -6931,6 +7004,12 @@ } } }, + "teleinfo": { + "name": "Teleinfo", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "telldus": { "name": "Telldus", "integrations": { @@ -7593,6 +7672,12 @@ "victron": { "name": "Victron", "integrations": { + "victron_gx": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Victron GX" + }, "victron_ble": { "integration_type": "device", "config_flow": true, @@ -8163,6 +8248,12 @@ "zwave" ] }, + "zunzunbee": { + "name": "Zunzunbee", + "iot_standards": [ + "zigbee" + ] + }, "zwave_js": { "name": "Z-Wave", "integration_type": "hub", @@ -8256,6 +8347,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 d7777d68ff1..45b5c803fd3 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -341,25 +341,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", @@ -391,6 +373,12 @@ SSDP = { "nt": "urn:schemas-upnp-org:device:InternetGatewayDevice:2", }, ], + "victron_gx": [ + { + "X_MqttOnLan": "1", + "manufacturer": "Victron Energy", + }, + ], "webostv": [ { "st": "urn:lge-com:service:webos-second-screen:1", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index d1974f23d6e..70da80846d8 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -58,6 +58,16 @@ USB = [ "pid": "0003", "vid": "04B4", }, + { + "domain": "teleinfo", + "pid": "6015", + "vid": "0403", + }, + { + "domain": "teleinfo", + "pid": "EA60", + "vid": "10C4", + }, { "domain": "velbus", "pid": "0B1B", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 50bb4f31414..7bce7c8cc76 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.", @@ -615,6 +619,14 @@ ZEROCONF = { "domain": "loqed", "name": "loqed*", }, + { + "domain": "lunatone", + "properties": { + "manufacturer": "lunatone industrielle elektronik gmbh", + "type": "dali-2-*", + "uid": "*", + }, + }, { "domain": "nam", "name": "nam-*", @@ -695,6 +707,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..41a48290ae3 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 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..6cf685ea5c9 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, 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 5cf8df5d36c..8f29a86a73a 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,8 +14,10 @@ import sys from typing import ( TYPE_CHECKING, Any, + ClassVar, Final, Literal, + Never, Protocol, TypedDict, Unpack, @@ -93,7 +93,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, @@ -230,19 +235,23 @@ async def _register_condition_platform( from homeassistant.components import automation # noqa: PLC0415 new_conditions: set[str] = set() + conditions = hass.data[CONDITIONS] if hasattr(platform, "async_get_conditions"): - for condition_key in await platform.async_get_conditions(hass): + all_conditions = await platform.async_get_conditions(hass) + for condition_key in all_conditions: condition_key = get_absolute_description_key( integration_domain, condition_key ) - hass.data[CONDITIONS][condition_key] = integration_domain - new_conditions.add(condition_key) + if condition_key not in conditions: + conditions[condition_key] = integration_domain + new_conditions.add(condition_key) if not new_conditions: - _LOGGER.debug( - "Integration %s returned no conditions in async_get_conditions", - integration_domain, - ) + if not all_conditions: + _LOGGER.debug( + "Integration %s returned no conditions in async_get_conditions", + integration_domain, + ) return else: _LOGGER.debug( @@ -280,10 +289,95 @@ _CONDITION_SCHEMA = _CONDITION_BASE_SCHEMA.extend( ) -class Condition(abc.ABC): - """Condition class.""" +class ConditionChecker(abc.ABC): + """Base class for condition checkers.""" - _hass: HomeAssistant + def __init__(self, hass: HomeAssistant) -> None: + """Initialize condition checker.""" + self._hass = hass + self._unloaded = False + + 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") + + async def async_setup(self) -> None: + """Set up the condition checker. + + Intended to be overridden in derived classes that need to do setup. + """ + + def async_unload(self) -> None: + """Clean up any resources held by the checker. + + Intended to be overridden in derived classes that need to do unloading. + """ + self._unloaded = True + + def async_check( + self, *, variables: TemplateVarsType = None, **kwargs: Never + ) -> bool | None: + """Check the condition.""" + 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() + super().async_unload() + + +class Condition(ConditionChecker): + """Condition class.""" @classmethod async def async_validate_complete_config( @@ -319,11 +413,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" @@ -333,10 +423,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, }, } ) @@ -346,7 +437,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 @@ -362,13 +459,192 @@ 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.""" + await super().async_setup() + 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.""" + super().async_unload() + 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] @@ -376,53 +652,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 @@ -440,6 +669,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). @@ -458,6 +689,7 @@ def make_entity_state_condition( _domain_specs = specs _states = states_set + _primary_entities_only = primary_entities_only return CustomCondition @@ -565,6 +797,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) @@ -574,6 +808,7 @@ def make_entity_numerical_condition( _domain_specs = specs _valid_unit = valid_unit + _primary_entities_only = primary_entities_only return CustomCondition @@ -703,13 +938,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 @@ -821,27 +1049,22 @@ async def _async_get_condition_platform( f'Invalid condition "{condition_key}" specified' ) from None try: - return platform, await integration.async_get_platform("condition") + platform_module = await integration.async_get_platform("condition") except ImportError: raise HomeAssistantError( f"Integration '{platform}' does not provide condition support" ) from None + # Ensure conditions are registered so descriptions can be loaded + await _register_condition_platform(hass, 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 + return platform, platform_module 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. @@ -857,15 +1080,7 @@ 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 + return DisabledConditionChecker(hass) condition_key: str = config[CONF_CONDITION] factory: Any = None @@ -884,7 +1099,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) @@ -898,30 +1114,40 @@ async def async_from_config( check_factory = check_factory.func 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 isinstance(checker, ConditionChecker): + await checker.async_setup() + return checker + return LegacyConditionChecker(hass, cast(ConditionCheckerType, 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 @@ -930,29 +1156,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 @@ -961,29 +1190,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 @@ -992,8 +1224,6 @@ async def async_not_from_config( return True - return if_not_condition - def numeric_state( hass: HomeAssistant, @@ -1150,7 +1380,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: @@ -1269,7 +1498,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 = [] @@ -1331,7 +1559,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) @@ -1375,7 +1602,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, @@ -1405,7 +1632,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, @@ -1444,7 +1671,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) @@ -1458,7 +1684,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 ( @@ -1547,40 +1772,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..92a3a281216 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 @@ -759,15 +757,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) diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 9ace020f342..29183d9eae5 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 diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index 67d6ad55a3a..4941b496046 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 diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 6dfb002305a..8f4dfca2110 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -1,7 +1,5 @@ """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 diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index dc2f083c90e..850b80ff617 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 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..8ada64d864e 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 @@ -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. diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index ca46be3d934..e92485ccc21 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] @@ -96,7 +94,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 +105,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 +240,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 9ad5fbd5f61..623cc370b6b 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 @@ -787,7 +785,7 @@ class EntityPlatform: already_exists = True return (already_exists, restored) - async def _async_add_entity( + async def _async_add_entity( # noqa: C901 self, entity: Entity, update_before_add: bool, @@ -822,29 +820,41 @@ class EntityPlatform: # An entity may suggest the entity_id by setting entity_id itself if not hasattr(entity, "internal_integration_suggested_object_id"): - if entity.entity_id is not None and not valid_entity_id(entity.entity_id): - if entity.unique_id is not None: - report_usage( - f"sets an invalid entity ID: '{entity.entity_id}'. " - "In most cases, entities should not set entity_id," - " but if they do, it should be a valid entity ID.", - integration_domain=self.platform_name, - breaks_in_ha_version="2027.2.0", + if entity.entity_id is None: + entity.internal_integration_suggested_object_id = None # type: ignore[unreachable] + else: + if not valid_entity_id(entity.entity_id): + if entity.unique_id is not None: + report_usage( + f"sets an invalid entity ID: '{entity.entity_id}'. " + "In most cases, entities should not set entity_id," + " but if they do, it should be a valid entity ID", + integration_domain=self.platform_name, + breaks_in_ha_version="2027.2.0", + ) + else: + entity.add_to_platform_abort() + raise HomeAssistantError( + f"Invalid entity ID: {entity.entity_id}" + ) + try: + domain, entity.internal_integration_suggested_object_id = ( + split_entity_id(entity.entity_id) ) - else: + if domain != self.domain: + report_usage( + f"sets an entity ID with wrong domain: '{entity.entity_id}'. " + f"Expected domain is '{self.domain}'", + integration_domain=self.platform_name, + breaks_in_ha_version="2027.5.0", + ) + except ValueError: + # This error handling should be removed once we remove + # the invalid entity ID deprecation above. entity.add_to_platform_abort() - raise HomeAssistantError(f"Invalid entity ID: {entity.entity_id}") - try: - entity.internal_integration_suggested_object_id = ( - split_entity_id(entity.entity_id)[1] - if entity.entity_id is not None - else None - ) - except ValueError: - entity.add_to_platform_abort() - raise HomeAssistantError( - f"Invalid entity ID: {entity.entity_id}" - ) from None + raise HomeAssistantError( + f"Invalid entity ID: {entity.entity_id}" + ) from None # Get entity_id from unique ID registration if entity.unique_id is not None: diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 851ab2c8990..9e5f75a37b4 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 @@ -458,6 +456,27 @@ class RegistryEntry: hass.states.async_set(self.entity_id, STATE_UNAVAILABLE, attrs) +@callback +def async_get_unprefixed_name(hass: HomeAssistant, entry: RegistryEntry) -> str: + """Get the entity name with device name prefix stripped, if applicable.""" + name = entry.name + if name is not None: + if ( + entry.device_id is not None + and (device := dr.async_get(hass).async_get(entry.device_id)) is not None + ): + device_name = device.name_by_user or device.name + unprefixed_name = _async_strip_prefix_from_entity_name(name, device_name) + if unprefixed_name is not None: + return unprefixed_name + return name + + if entry.original_name_unprefixed is not None: + return entry.original_name_unprefixed + + return entry.original_name or "" + + @callback def _async_get_full_entity_name( hass: HomeAssistant, @@ -469,6 +488,7 @@ def _async_get_full_entity_name( original_name: str | None, original_name_unprefixed: str | None | UndefinedType = UNDEFINED, overridden_name: str | None = None, + unprefix_name: bool = False, use_legacy_naming: bool = False, ) -> str: """Get full name for an entity. @@ -500,6 +520,10 @@ def _async_get_full_entity_name( if original_name_unprefixed is not None else original_name ) + elif unprefix_name: + unprefixed_name = _async_strip_prefix_from_entity_name(name, device_name) + if unprefixed_name is not None: + name = unprefixed_name if not name: name = device_name @@ -1235,6 +1259,7 @@ class EntityRegistry(BaseRegistry): name=name, original_name=object_id_base, overridden_name=suggested_object_id, + unprefix_name=True, ) return self.async_get_available_entity_id( domain, 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..ccc7e4b9bc2 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 @@ -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..9dab354b2f1 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -1,7 +1,5 @@ """Helper to track the current http request.""" -from __future__ import annotations - from collections.abc import Awaitable, Callable from contextvars import ContextVar from http import HTTPStatus 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..e12794aec0a 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 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..2a1ef3964db 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, @@ -774,7 +768,6 @@ def async_match_targets( # noqa: C901 @callback -@bind_hass def async_match_states( hass: HomeAssistant, name: str | None = None, @@ -1181,17 +1174,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): @@ -1440,16 +1427,16 @@ 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] = {} 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..d8baf9862c5 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 @@ -1160,6 +1158,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. @@ -1173,7 +1191,25 @@ class GetLiveContextTool(Tool): "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)." + "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 +1224,62 @@ 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())), + 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..283d7c7292f 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -1,7 +1,5 @@ """Network helpers.""" -from __future__ import annotations - from collections.abc import Callable from contextlib import suppress from ipaddress import ip_address @@ -12,7 +10,6 @@ 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 @@ -27,7 +24,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 +35,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 +109,6 @@ def is_hass_url(hass: HomeAssistant, url: str) -> bool: return False -@bind_hass def get_url( hass: HomeAssistant, *, @@ -229,7 +223,6 @@ def _get_request_host() -> str | None: return host -@bind_hass def _get_internal_url( hass: HomeAssistant, *, @@ -267,7 +260,6 @@ def _get_internal_url( raise NoURLAvailableError -@bind_hass def _get_external_url( hass: HomeAssistant, *, @@ -312,7 +304,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/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..e0b513fff5a 100644 --- a/homeassistant/helpers/reload.py +++ b/homeassistant/helpers/reload.py @@ -1,7 +1,5 @@ """Class to reload platforms.""" -from __future__ import annotations - import asyncio from collections.abc import Iterable import logging 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..104d7046c9b 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 diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 98f23ecd47e..015771082cf 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" ) @@ -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 @@ -1358,7 +1365,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 +1386,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 +1422,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 +1467,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 +1505,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 +1787,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: @@ -1889,13 +1913,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..9140c342dfc 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 diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 3194de03dc5..9eae351ae43 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 @@ -422,6 +420,69 @@ class AttributeSelector(Selector[AttributeSelectorConfig]): return attribute +class AutomationBehavior(StrEnum): + """Possible behaviors for an automation behavior selector.""" + + ALL = "all" + FIRST = "first" + LAST = "last" + 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.LAST, + AutomationBehavior.ANY, + ], + 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.""" @@ -1771,6 +1832,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 +1945,7 @@ class TargetSelectorConfig(BaseSelectorConfig, total=False): entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] + primary_entities_only: bool @SELECTORS.register("target") @@ -1877,6 +1967,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..72f77254f7e 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, @@ -1123,7 +1197,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 +1233,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 +1307,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 +1330,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..180e2481d72 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.""" 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 725b303c79b..2883b7c0502 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -1,21 +1,17 @@ """Helper to gather system info.""" -from __future__ import annotations - from functools import cache from getpass import getuser import logging import platform -from typing import TYPE_CHECKING, Any +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 from .hassio import is_hassio -from .importlib import async_import_module from .singleton import singleton _LOGGER = logging.getLogger(__name__) @@ -51,32 +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.""" - # Local import to avoid circular dependencies - # We use the import helper because hassio - # may not be loaded yet and we don't want to - # do blocking I/O in the event loop to import it. - if TYPE_CHECKING: - from homeassistant.components import hassio # noqa: PLC0415 - else: - hassio = await async_import_module(hass, "homeassistant.components.hassio") - 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: @@ -105,6 +91,9 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: # Enrich with Supervisor information if is_hassio_: + # Local import to avoid circular dependencies + from homeassistant.components import hassio # noqa: PLC0415 + if not (info := hassio.get_info(hass)): _LOGGER.warning("No Home Assistant Supervisor info available") info = {} diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 334b7147e01..04aa9c8bf0b 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] = [] @@ -300,7 +307,10 @@ class TargetEntityChangeTracker(abc.ABC): 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 @@ -345,14 +355,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 +408,26 @@ 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 referenced directly or indirectly in a target selector. + + When `primary_entities_only` is True, indirect target expansion (via device, area, + and floor) skips entities with an `entity_category` (i.e. 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" ) - 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 df033135460..55eceb24fa3 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -1,73 +1,32 @@ """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 copy import deepcopy -from datetime import datetime, timedelta -from enum import Enum -from functools import cache, lru_cache, partial, wraps -import json +from collections.abc import Callable +from datetime import timedelta +from functools import lru_cache, partial import logging -import math -from operator import contains import pathlib -import random import re -from struct import error as StructError, pack, unpack_from 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 -import orjson -from propcache.api import under_cached_property -import voluptuous as vol -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, - ServiceResponse, - 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.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 ( @@ -76,8 +35,19 @@ from .context import ( template_context_manager, template_cv, ) -from .helpers import raise_no_default +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 @@ -100,72 +70,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 - -ORJSON_PASSTHROUGH_OPTIONS = ( - orjson.OPT_PASSTHROUGH_DATACLASS | orjson.OPT_PASSTHROUGH_DATETIME -) - - -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.""" @@ -340,27 +249,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 @@ -375,8 +268,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( @@ -704,1105 +595,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 "