diff --git a/.claude/agents/raise-pull-request.md b/.claude/agents/raise-pull-request.md new file mode 100644 index 00000000000000..e466df6fa7e88d --- /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 3d3586eb0f45ce..3e4fa4aa49bf22 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 00000000000000..5e64606d1df219 --- /dev/null +++ b/.claude/skills/ha-integration-knowledge/SKILL.md @@ -0,0 +1,43 @@ +--- +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. + +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 + + +## 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 00000000000000..8d3fa73cd974a7 --- /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 00000000000000..269db92239bc32 --- /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 2bf861a9c8b963..00000000000000 --- 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 2d01cd08a62289..00000000000000 --- 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 08d631dd5f0a5d..00000000000000 --- 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 62a787df0fd96e..ea08fd4a53cdb0 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 3bc651eb2f21a1..0c23b5fd727c08 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,16 +5,15 @@ # 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. # 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,7 +21,7 @@ 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. +- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. Never flag this as an issue since Home Assistant officially supports Python 3.14. ## Testing @@ -33,7 +32,5 @@ Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) ov 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 +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. diff --git a/.github/instructions/integrations.instructions.md b/.github/instructions/integrations.instructions.md new file mode 100644 index 00000000000000..35971f14cb00aa --- /dev/null +++ b/.github/instructions/integrations.instructions.md @@ -0,0 +1,46 @@ +--- +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. + +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 + + +## 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 00000000000000..2f79e54020f514 --- /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 9cf6a40bf050de..7aebea1ff2cb13 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -47,10 +47,6 @@ jobs: with: python-version-file: ".python-version" - - name: Get information - id: info - uses: home-assistant/actions/helpers/info@5f5b077d63a1e4c53019231409a0c4d791fb74e5 # 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@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16 + uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 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@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16 + uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: OHF-Voice/intents-package @@ -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 eb35d71dfa8a53..6d8c73a00e97a3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 3 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2026.4" + HA_SHORT_VERSION: "2026.5" 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 +50,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 +282,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@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1 + uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2 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 +303,7 @@ jobs: with: persist-credentials: false - name: Run zizmor - uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1.1.1 + uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2 with: extra-args: --all-files zizmor @@ -364,7 +366,7 @@ jobs: 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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv key: >- @@ -372,7 +374,8 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore uv wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + id: cache-uv + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ${{ env.UV_CACHE_DIR }} key: >- @@ -384,7 +387,7 @@ jobs: env.HA_SHORT_VERSION }}- - name: Check if apt cache exists id: cache-apt-check - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: lookup-only: ${{ steps.cache-venv.outputs.cache-hit == 'true' }} path: | @@ -396,6 +399,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 +433,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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + 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 +446,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 @@ -456,7 +464,7 @@ 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 @@ -469,6 +477,26 @@ jobs: - name: Check dirty run: | ./script/check_dirty + - name: Save uv wheel cache + if: | + (success() && steps.cache-venv.outputs.cache-hit != 'true') + || (always() + && steps.create-venv.outcome == 'success' + && steps.cache-uv.outputs.cache-matched-key == '') + uses: actions/cache/save@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 }} + - 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 +512,7 @@ jobs: && github.event.inputs.audit-licenses-only != 'true' steps: - name: Restore apt cache - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.APT_CACHE_DIR }} @@ -515,7 +543,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -552,7 +580,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -643,7 +671,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -657,7 +685,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 +722,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -747,7 +775,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -804,7 +832,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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -812,7 +840,7 @@ jobs: ${{ runner.os }}-${{ runner.arch }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .mypy_cache key: >- @@ -854,7 +882,7 @@ jobs: - base steps: - name: Restore apt cache - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.APT_CACHE_DIR }} @@ -887,7 +915,7 @@ jobs: check-latest: true - name: Restore full Python virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -901,7 +929,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 +958,7 @@ jobs: group: ${{ fromJson(needs.info.outputs.test_groups) }} steps: - name: Restore apt cache - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.APT_CACHE_DIR }} @@ -964,7 +992,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -1020,14 +1048,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 +1068,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 +1090,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 +1110,7 @@ jobs: mariadb-group: ${{ fromJson(needs.info.outputs.mariadb_groups) }} steps: - name: Restore apt cache - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.APT_CACHE_DIR }} @@ -1115,7 +1145,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -1177,7 +1207,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 +1215,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 +1229,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 +1268,7 @@ jobs: postgresql-group: ${{ fromJson(needs.info.outputs.postgresql_groups) }} steps: - name: Restore apt cache - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.APT_CACHE_DIR }} @@ -1275,7 +1305,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -1338,7 +1368,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 +1376,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 +1390,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 +1422,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 with: fail_ci_if_error: true flags: full-suite @@ -1421,7 +1451,7 @@ jobs: group: ${{ fromJson(needs.info.outputs.test_groups) }} steps: - name: Restore apt cache - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.APT_CACHE_DIR }} @@ -1455,7 +1485,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: venv fail-on-cache-miss: true @@ -1514,14 +1544,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 +1564,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 +1593,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + 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 +1621,7 @@ jobs: with: pattern: test-results-* - name: Upload test results to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 + 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 b0d1025642ed0c..53aeea74705d4c 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 8270a2040a968c..6fb8fc52dee63b 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 cab2b728b32184..01bf999cf2f737 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 96828d06931b54..6c10806333aeec 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 86ead98ad59aa2..db0472c6834025 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 77b5cc6933b47f..9d8cbaf15e091a 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 018b971cbe2e51..6be30da9f601aa 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/.strict-typing b/.strict-typing index e811362e91fcf1..43ddeb282dd7f7 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.* @@ -174,9 +175,11 @@ homeassistant.components.dnsip.* homeassistant.components.doorbird.* homeassistant.components.dormakaba_dkey.* homeassistant.components.downloader.* +homeassistant.components.dropbox.* homeassistant.components.droplet.* homeassistant.components.dsmr.* homeassistant.components.duckdns.* +homeassistant.components.duco.* homeassistant.components.dunehd.* homeassistant.components.duotecno.* homeassistant.components.easyenergy.* @@ -221,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.* @@ -331,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.* @@ -550,6 +555,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.* @@ -578,6 +584,7 @@ homeassistant.components.trmnl.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* homeassistant.components.unifi.* +homeassistant.components.unifi_access.* homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* homeassistant.components.update.* @@ -592,6 +599,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 888d93ec07eaff..406a618c2b1db6 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,7 +12,7 @@ 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. +- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses. Never flag this as an issue since Home Assistant officially supports Python 3.14. ## Testing @@ -23,3 +22,6 @@ Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) ov ## 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. + +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. diff --git a/CODEOWNERS b/CODEOWNERS index 03f67311ad2fdb..715903bcffe0a5 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 @@ -401,6 +412,8 @@ build.json @home-assistant/supervisor /tests/components/dremel_3d_printer/ @tkdrob /homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer /tests/components/drop_connect/ @ChandlerSystems @pfrazer +/homeassistant/components/dropbox/ @bdr99 +/tests/components/dropbox/ @bdr99 /homeassistant/components/droplet/ @sarahseidman /tests/components/droplet/ @sarahseidman /homeassistant/components/dsmr/ @Robbie1221 @@ -409,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 @@ -417,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 @@ -478,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 @@ -494,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 @@ -553,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 @@ -575,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 @@ -737,10 +758,12 @@ 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 -/tests/components/html5/ @alexyao2015 +/homeassistant/components/html5/ @alexyao2015 @tr4nt0r +/tests/components/html5/ @alexyao2015 @tr4nt0r /homeassistant/components/http/ @home-assistant/core /tests/components/http/ @home-assistant/core /homeassistant/components/huawei_lte/ @scop @fphammerle @@ -828,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 @@ -885,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 @@ -899,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 @@ -1044,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 @@ -1137,8 +1162,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 @@ -1178,6 +1203,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 @@ -1214,6 +1241,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 @@ -1226,12 +1255,14 @@ build.json @home-assistant/supervisor /tests/components/onewire/ @garbled1 @epenet /homeassistant/components/onkyo/ @arturpragacz @eclair4151 /tests/components/onkyo/ @arturpragacz @eclair4151 -/homeassistant/components/onvif/ @hunterjm @jterrace -/tests/components/onvif/ @hunterjm @jterrace +/homeassistant/components/onvif/ @jterrace +/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 @@ -1254,8 +1285,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 @@ -1299,6 +1330,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 @@ -1388,6 +1421,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 @@ -1679,8 +1714,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 @@ -1711,6 +1746,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 @@ -1813,6 +1850,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 @@ -1860,10 +1899,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 @@ -1950,8 +1991,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 @@ -1962,8 +2003,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 5668f4f4c6c888..5ce38cd1656650 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.6 - -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 cdb2db56267c4b..1248979211b465 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/auth/jwt_wrapper.py b/homeassistant/auth/jwt_wrapper.py index 464df006f5f171..0171e388f142c8 100644 --- a/homeassistant/auth/jwt_wrapper.py +++ b/homeassistant/auth/jwt_wrapper.py @@ -7,23 +7,31 @@ 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 +46,6 @@ def _load(self, jwt: str | bytes) -> tuple[bytes, bytes, dict, bytes]: 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 +61,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 +75,70 @@ def verify_and_decode( 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, + issuer=issuer, + leeway=leeway, + options=options, ) - # 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, + + @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, ) - return payload + + @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/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 6498483a19a14d..21453f0337998f 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -2,7 +2,8 @@ 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 +14,9 @@ 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 +26,21 @@ "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/brands/denon.json b/homeassistant/brands/denon.json index a60750e1a31985..d4f68c1306e386 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 37cd6d8ce732e0..001db20de07afe 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 00000000000000..4825bd55326dca --- /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 bcc6349532420c..47f7bad226185c 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 e8508b389aa063..8d01e456b69d57 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 00000000000000..d1c67a9cfc9a22 --- /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 525fc60e930773..195f501fb24278 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -30,7 +30,7 @@ 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 +67,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 +102,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 +186,6 @@ def event_callback(event: str, event_json: dict[str, str]) -> 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 161ef315b806af..327a63badaa855 100644 --- a/homeassistant/components/abode/alarm_control_panel.py +++ b/homeassistant/components/abode/alarm_control_panel.py @@ -9,21 +9,20 @@ 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 a3fce63ddf22fc..a866e1dd9f00ef 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -10,22 +10,21 @@ 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 4d81fba9172899..a7ae3cd0b1fb7b 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -12,14 +12,13 @@ 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 +26,11 @@ 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/const.py b/homeassistant/components/abode/const.py index 0279b89f7d43b0..5cd2393af70de2 100644 --- a/homeassistant/components/abode/const.py +++ b/homeassistant/components/abode/const.py @@ -3,17 +3,10 @@ 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 dd70ea765ba87b..1a81d04b09e518 100644 --- a/homeassistant/components/abode/cover.py +++ b/homeassistant/components/abode/cover.py @@ -5,21 +5,20 @@ 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 d74e7990328a48..087a87d2ce72ba 100644 --- a/homeassistant/components/abode/entity.py +++ b/homeassistant/components/abode/entity.py @@ -7,7 +7,7 @@ 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 @@ async def async_added_to_hass(self) -> None: 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 cee402d3cb6e37..1d0f2f7275e3a9 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -16,21 +16,20 @@ 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 9e4c45453e5e61..aad7838cab57dc 100644 --- a/homeassistant/components/abode/lock.py +++ b/homeassistant/components/abode/lock.py @@ -5,21 +5,20 @@ 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 72eee7eccf2804..a1597d7d41f638 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -14,13 +14,11 @@ 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 +64,11 @@ class AbodeSensorDescription(SensorEntityDescription): 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 5b2a05f52287b2..70ff5ab16964f1 100644 --- a/homeassistant/components/abode/services.py +++ b/homeassistant/components/abode/services.py @@ -2,15 +2,21 @@ 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 +31,21 @@ 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 +56,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 +71,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 7eecea514fed18..751c6c9b3d2011 100644 --- a/homeassistant/components/abode/switch.py +++ b/homeassistant/components/abode/switch.py @@ -7,12 +7,11 @@ 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 +19,11 @@ 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/sensor.py b/homeassistant/components/acaia/sensor.py index f62b93ddf1dc0e..6694bbb99664c2 100644 --- a/homeassistant/components/acaia/sensor.py +++ b/homeassistant/components/acaia/sensor.py @@ -143,4 +143,4 @@ def _handle_coordinator_update(self) -> None: @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/config_flow.py b/homeassistant/components/accuweather/config_flow.py index a56391e9c4f05e..a15dc9609ed302 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -4,7 +4,7 @@ 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 +12,7 @@ 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 +55,11 @@ async def async_step_user( ) 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 +73,6 @@ async def async_step_user( 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/coordinator.py b/homeassistant/components/accuweather/coordinator.py index 3c4991d2c59fbf..c8e37f45cf9d8b 100644 --- a/homeassistant/components/accuweather/coordinator.py +++ b/homeassistant/components/accuweather/coordinator.py @@ -64,7 +64,7 @@ def __init__( """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 +122,7 @@ def __init__( 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/strings.json b/homeassistant/components/accuweather/strings.json index 221452a63c9d19..ac6d15bd4774c6 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/acer_projector/const.py b/homeassistant/components/acer_projector/const.py index 95e32dc97d4b2f..7f1313e090ec97 100644 --- a/homeassistant/components/acer_projector/const.py +++ b/homeassistant/components/acer_projector/const.py @@ -6,10 +6,11 @@ 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 026374bf53d012..480a72bd09ece9 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.0"] } diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py index 846164202d80c1..73989067579c1c 100644 --- a/homeassistant/components/acer_projector/switch.py +++ b/homeassistant/components/acer_projector/switch.py @@ -6,7 +6,7 @@ import re from typing import Any -import serial +from serialx import Serial, SerialException import voluptuous as vol from homeassistant.components.switch import ( @@ -16,21 +16,22 @@ 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 +46,7 @@ { 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 +63,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 +78,14 @@ def __init__( 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 +95,26 @@ def __init__( 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/actiontec/__init__.py b/homeassistant/components/actiontec/__init__.py index fa59cc870633af..adcb750a794450 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/actron_air/__init__.py b/homeassistant/components/actron_air/__init__.py index 7048e76512fbe9..f8b460dd027972 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 8c928fcc5a99d1..efae5467a2a8d1 100644 --- a/homeassistant/components/actron_air/climate.py +++ b/homeassistant/components/actron_air/climate.py @@ -15,10 +15,12 @@ ) 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 @@ "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 @@ def __init__( 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 @@ def current_temperature(self) -> float: @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 @@ def __init__( 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 @@ def current_temperature(self) -> float | None: @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 3faefe7590fa47..e03b6bbdebd4b9 100644 --- a/homeassistant/components/actron_air/config_flow.py +++ b/homeassistant/components/actron_air/config_flow.py @@ -23,7 +23,7 @@ def __init__(self) -> None: 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 +38,10 @@ async def async_step_user( _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 +94,7 @@ async def async_step_finish_login( _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 @@ -107,7 +107,7 @@ async def async_step_finish_login( 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}, ) diff --git a/homeassistant/components/actron_air/coordinator.py b/homeassistant/components/actron_air/coordinator.py index a69f7ab56b06dd..6876e0ae2f04aa 100644 --- a/homeassistant/components/actron_air/coordinator.py +++ b/homeassistant/components/actron_air/coordinator.py @@ -6,12 +6,12 @@ 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 +38,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 +46,7 @@ def __init__( hass: HomeAssistant, entry: ActronAirConfigEntry, api: ActronAirAPI, - system: ActronAirACSystem, + system: ActronAirSystemInfo, ) -> None: """Initialize the coordinator.""" super().__init__( @@ -57,7 +57,7 @@ def __init__( 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 +78,14 @@ async def _async_update_data(self) -> ActronAirStatus: 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 00000000000000..0cfa668a37c128 --- /dev/null +++ b/homeassistant/components/actron_air/diagnostics.py @@ -0,0 +1,35 @@ +"""Diagnostics support for Actron Air.""" + +from __future__ import annotations + +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 7f62c53516e2ab..ec69232101bde2 100644 --- a/homeassistant/components/actron_air/entity.py +++ b/homeassistant/components/actron_air/entity.py @@ -14,13 +14,17 @@ 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 @@ async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None: 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 724ff101cb96ee..06978d83b460c4 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 240b3e4b185175..c74421b177e4de 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,18 +54,12 @@ 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 + entity-category: done + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: done + icon-translations: done reconfiguration-flow: todo repair-issues: status: exempt @@ -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 9e22a6ffb86f2b..fc17e510e998ba 100644 --- a/homeassistant/components/actron_air/strings.json +++ b/homeassistant/components/actron_air/strings.json @@ -55,6 +55,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 44efe6c9f7461a..113be86171dcf6 100644 --- a/homeassistant/components/actron_air/switch.py +++ b/homeassistant/components/actron_air/switch.py @@ -10,7 +10,7 @@ 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 @@ def is_on(self) -> bool: """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/ai_task/media_source.py b/homeassistant/components/ai_task/media_source.py index 61a212be5b0651..57c06e278f05e7 100644 --- a/homeassistant/components/ai_task/media_source.py +++ b/homeassistant/components/ai_task/media_source.py @@ -25,7 +25,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 1d27f75b6c7f1d..d064ee2f5e6256 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -74,7 +74,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 +90,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/conditions.yaml b/homeassistant/components/air_quality/conditions.yaml index 97b7c1056dadba..ef7b6b18c55e7c 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 996bf26005702c..54af78fca3b886 100644 --- a/homeassistant/components/air_quality/strings.json +++ b/homeassistant/components/air_quality/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -12,6 +14,9 @@ "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::condition_threshold_name%]" } @@ -23,6 +28,9 @@ "fields": { "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" } }, "name": "Carbon monoxide cleared" @@ -32,6 +40,9 @@ "fields": { "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" } }, "name": "Carbon monoxide detected" @@ -42,6 +53,9 @@ "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::condition_threshold_name%]" } @@ -53,6 +67,9 @@ "fields": { "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" } }, "name": "Gas cleared" @@ -62,6 +79,9 @@ "fields": { "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" } }, "name": "Gas detected" @@ -72,6 +92,9 @@ "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::condition_threshold_name%]" } @@ -84,6 +107,9 @@ "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::condition_threshold_name%]" } @@ -96,6 +122,9 @@ "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::condition_threshold_name%]" } @@ -108,6 +137,9 @@ "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::condition_threshold_name%]" } @@ -120,6 +152,9 @@ "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::condition_threshold_name%]" } @@ -132,6 +167,9 @@ "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::condition_threshold_name%]" } @@ -144,6 +182,9 @@ "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::condition_threshold_name%]" } @@ -156,6 +197,9 @@ "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::condition_threshold_name%]" } @@ -167,6 +211,9 @@ "fields": { "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" } }, "name": "Smoke cleared" @@ -176,6 +223,9 @@ "fields": { "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" } }, "name": "Smoke detected" @@ -186,6 +236,9 @@ "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::condition_threshold_name%]" } @@ -198,6 +251,9 @@ "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::condition_threshold_name%]" } @@ -210,6 +266,9 @@ "behavior": { "name": "[%key:component::air_quality::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::condition_threshold_name%]" } @@ -217,21 +276,6 @@ "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": { @@ -249,6 +293,9 @@ "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } @@ -269,6 +316,9 @@ "fields": { "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" } }, "name": "Carbon monoxide cleared" @@ -279,6 +329,9 @@ "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } @@ -290,6 +343,9 @@ "fields": { "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" } }, "name": "Carbon monoxide detected" @@ -299,6 +355,9 @@ "fields": { "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" } }, "name": "Gas cleared" @@ -308,6 +367,9 @@ "fields": { "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" } }, "name": "Gas detected" @@ -327,6 +389,9 @@ "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } @@ -348,6 +413,9 @@ "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } @@ -369,6 +437,9 @@ "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } @@ -390,6 +461,9 @@ "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } @@ -411,6 +485,9 @@ "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } @@ -432,6 +509,9 @@ "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } @@ -453,6 +533,9 @@ "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } @@ -474,6 +557,9 @@ "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } @@ -485,6 +571,9 @@ "fields": { "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" } }, "name": "Smoke cleared" @@ -494,6 +583,9 @@ "fields": { "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" } }, "name": "Smoke detected" @@ -513,6 +605,9 @@ "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } @@ -534,6 +629,9 @@ "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::air_quality::common::trigger_threshold_name%]" } @@ -555,6 +653,9 @@ "behavior": { "name": "[%key:component::air_quality::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::air_quality::common::trigger_for_name%]" + }, "threshold": { "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 e453aeeb87564c..1992cc1f039005 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/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index d3f2240a37c7d3..5f0354848a7625 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -12,11 +12,11 @@ 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 +45,16 @@ async def async_step_user( 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 +68,7 @@ async def async_step_user( 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 +83,6 @@ async def async_step_user( 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 5939bfa62de244..6dc00ddcc8618a 100644 --- a/homeassistant/components/airly/const.py +++ b/homeassistant/components/airly/const.py @@ -37,3 +37,5 @@ 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/sensor.py b/homeassistant/components/airly/sensor.py index 2aa99d9c792a00..64a68e499713ae 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -127,7 +127,7 @@ class AirlySensorEntityDescription(SensorEntityDescription): ), 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 +178,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 6f53c7ed23cb79..4c3a50b194b06d 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/airos/__init__.py b/homeassistant/components/airos/__init__.py index a0e573f2f50a22..1942059b1e5289 100644 --- a/homeassistant/components/airos/__init__.py +++ b/homeassistant/components/airos/__init__.py @@ -33,14 +33,21 @@ 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 +93,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 0154db8dcb511c..ced58410e9d63b 100644 --- a/homeassistant/components/airos/binary_sensor.py +++ b/homeassistant/components/airos/binary_sensor.py @@ -87,7 +87,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 44eca04b9b6473..1f60352947abd6 100644 --- a/homeassistant/components/airos/button.py +++ b/homeassistant/components/airos/button.py @@ -31,7 +31,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/const.py b/homeassistant/components/airos/const.py index 548c4eff805de3..8e268a28d54831 100644 --- a/homeassistant/components/airos/const.py +++ b/homeassistant/components/airos/const.py @@ -5,6 +5,7 @@ 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 52ca88faebeb5d..8748300b329d87 100644 --- a/homeassistant/components/airos/coordinator.py +++ b/homeassistant/components/airos/coordinator.py @@ -2,7 +2,10 @@ 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 +22,61 @@ 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 +98,33 @@ def __init__( ) 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 70fef685c86895..4e006fedffd963 100644 --- a/homeassistant/components/airos/diagnostics.py +++ b/homeassistant/components/airos/diagnostics.py @@ -29,5 +29,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/sensor.py b/homeassistant/components/airos/sensor.py index 8b0673e241c74a..7b1b7a20b06bc7 100644 --- a/homeassistant/components/airos/sensor.py +++ b/homeassistant/components/airos/sensor.py @@ -180,7 +180,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 56026eac5529aa..fad6af5d58c3c6 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 00000000000000..fa79c9b01a1d7b --- /dev/null +++ b/homeassistant/components/airos/update.py @@ -0,0 +1,101 @@ +"""AirOS update component for Home Assistant.""" + +from __future__ import annotations + +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/airtouch5/config_flow.py b/homeassistant/components/airtouch5/config_flow.py index 38c85e45fb867c..72d20f5c49b110 100644 --- a/homeassistant/components/airtouch5/config_flow.py +++ b/homeassistant/components/airtouch5/config_flow.py @@ -36,6 +36,8 @@ async def async_step_user( _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/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 25e5426d23c476..5d82c8df682542 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -13,6 +13,9 @@ config_entry_oauth2_flow, device_registry as dr, ) +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from . import api from .const import CONFIG_FLOW_MINOR_VERSION, CONFIG_FLOW_VERSION, DOMAIN @@ -25,11 +28,17 @@ async def async_setup_entry( hass: HomeAssistant, entry: AladdinConnectConfigEntry ) -> bool: """Set up Aladdin Connect Genie from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) diff --git a/homeassistant/components/aladdin_connect/strings.json b/homeassistant/components/aladdin_connect/strings.json index a04552108a2007..2e7cc6f6961af9 100644 --- a/homeassistant/components/aladdin_connect/strings.json +++ b/homeassistant/components/aladdin_connect/strings.json @@ -37,6 +37,9 @@ "close_door_failed": { "message": "Failed to close the garage door" }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, "open_door_failed": { "message": "Failed to open the garage door" } diff --git a/homeassistant/components/alarm_control_panel/conditions.yaml b/homeassistant/components/alarm_control_panel/conditions.yaml index 12c5b700b3276f..ae4c27cf6eb2ed 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/strings.json b/homeassistant/components/alarm_control_panel/strings.json index c49e3d9df337bf..59dde5f886961d 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_armed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "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" @@ -18,6 +23,9 @@ "fields": { "behavior": { "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" @@ -27,6 +35,9 @@ "fields": { "behavior": { "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" @@ -36,6 +47,9 @@ "fields": { "behavior": { "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" @@ -45,6 +59,9 @@ "fields": { "behavior": { "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" @@ -54,6 +71,9 @@ "fields": { "behavior": { "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" @@ -63,6 +83,9 @@ "fields": { "behavior": { "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" @@ -140,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.", @@ -234,6 +242,9 @@ "fields": { "behavior": { "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::trigger_for_name%]" } }, "name": "Alarm armed" @@ -243,6 +254,9 @@ "fields": { "behavior": { "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" @@ -252,6 +266,9 @@ "fields": { "behavior": { "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" @@ -261,6 +278,9 @@ "fields": { "behavior": { "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" @@ -270,6 +290,9 @@ "fields": { "behavior": { "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" @@ -279,6 +302,9 @@ "fields": { "behavior": { "name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::alarm_control_panel::common::trigger_for_name%]" } }, "name": "Alarm disarmed" @@ -288,6 +314,9 @@ "fields": { "behavior": { "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 49723026f870d0..d5045e7976207b 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/config_flow.py b/homeassistant/components/alarmdecoder/config_flow.py index 093ed220973e45..7680dc47703e8e 100644 --- a/homeassistant/components/alarmdecoder/config_flow.py +++ b/homeassistant/components/alarmdecoder/config_flow.py @@ -3,10 +3,10 @@ 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 +102,21 @@ async def async_step_protocol( 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 +137,7 @@ def test_connection(): _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/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index af0a3d7818cc34..4e51047696937d 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -11,6 +11,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/alexa_devices/button.py b/homeassistant/components/alexa_devices/button.py new file mode 100644 index 00000000000000..9a735f550fc1e1 --- /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/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 8988d3e13cf785..a5414722baa806 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -12,12 +12,13 @@ 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 @@ -64,6 +65,13 @@ def __init__( 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.""" @@ -92,8 +100,13 @@ async def _async_update_data(self) -> 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( @@ -116,3 +129,23 @@ async def _async_remove_device_stale( 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/entity.py b/homeassistant/components/alexa_devices/entity.py index 21b01e26f6ccb8..57a67d9d31f876 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 @@ def available(self) -> bool: 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/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 241256fb5ca21a..45a1e0c6e8b84b 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -39,7 +39,6 @@ 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 +358,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/camera.py b/homeassistant/components/amcrest/camera.py index 5c3655e8d3115c..6f244c57f52bdc 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -12,13 +12,11 @@ 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 +27,13 @@ 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 +49,11 @@ 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 +221,7 @@ def extra_state_attributes(self) -> dict[str, Any]: 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 +268,7 @@ def async_on_demand_update(self) -> None: 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 +276,9 @@ async def async_added_to_hass(self) -> None: 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 +392,7 @@ async def async_stop_tour(self) -> None: 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 +549,10 @@ async def _async_goto_preset(self, preset: int) -> None: ) 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 377c5642b4b73f..67f37a826a28b7 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 @@ 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/services.py b/homeassistant/components/amcrest/services.py index 6b4ca8ade535f2..8102ed0059497c 100644 --- a/homeassistant/components/amcrest/services.py +++ b/homeassistant/components/amcrest/services.py @@ -1,62 +1,67 @@ -"""Support for Amcrest IP cameras.""" +"""Services for Amcrest IP cameras.""" from __future__ import annotations -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 +import voluptuous as vol -from .camera import CAMERA_SERVICES -from .const import CAMERAS, DATA_AMCREST, DOMAIN -from .helpers import service_signal +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 .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/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index e479c1836ec3e3..140e5cfb93e73a 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -2,34 +2,25 @@ from __future__ import annotations -import anthropic +from anthropic.resources.messages.messages import DEPRECATED_MODELS -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,26 +30,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(err) from err - except anthropic.AnthropicError as err: - raise ConfigEntryNotReady(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, @@ -248,6 +230,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 8701e28577eefa..5445b6543979e8 100644 --- a/homeassistant/components/anthropic/ai_task.py +++ b/homeassistant/components/anthropic/ai_task.py @@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.json import json_loads +from .const import DOMAIN from .entity import AnthropicBaseLLMEntity if TYPE_CHECKING: @@ -60,7 +61,7 @@ async def _async_generate_data( if not isinstance(chat_log.content[-1], conversation.AssistantContent): raise HomeAssistantError( - "Last content in chat log is not an AssistantContent" + translation_domain=DOMAIN, translation_key="response_not_found" ) text = chat_log.content[-1].content or "" @@ -78,7 +79,9 @@ async def _async_generate_data( err, text, ) - raise HomeAssistantError("Error with Claude structured response") from err + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="json_parse_error" + ) from err return ai_task.GenDataTaskResult( conversation_id=chat_log.conversation_id, diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 36c4a80f85d47b..51cc99f428648d 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -5,7 +5,6 @@ from collections.abc import Mapping import json import logging -import re from typing import TYPE_CHECKING, Any, cast import anthropic @@ -30,6 +29,7 @@ ) 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 +43,15 @@ 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 +63,11 @@ 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 +102,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 +198,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 +312,49 @@ async def async_step_advanced( ) -> 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=self._get_model_list(), custom_value=True) + ), + vol.Optional( + CONF_PROMPT_CACHING, + default=DEFAULT[CONF_PROMPT_CACHING], ): SelectSelector( SelectSelectorConfig( - options=await self._get_model_list(), custom_value=True + options=[x.value for x in PromptCaching], + translation_key=CONF_PROMPT_CACHING, + mode=SelectSelectorMode.DROPDOWN, ) ), - 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)), } 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 +364,7 @@ async def async_step_advanced( vol.Schema(step_schema), self.options ), errors=errors, + description_placeholders=description_placeholders, ) async def async_step_model( @@ -378,30 +373,59 @@ async def async_step_model( """Manage model-specific options.""" errors: dict[str, str] = {} - step_schema: VolDictType = {} - - model = self.options[CONF_CHAT_MODEL] + 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, + } - 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 +433,7 @@ async def async_step_model( ) ] = SelectSelector( SelectSelectorConfig( - options=["none", "low", "medium", "high", "max"], + options=effort_options, translation_key=CONF_THINKING_EFFORT, mode=SelectSelectorMode.DROPDOWN, ) @@ -417,47 +441,58 @@ async def async_step_model( 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 +524,16 @@ async def async_step_model( 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 138f704aa0cce2..a1fa522ffae6a7 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_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_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 00000000000000..0b9b0f9d02dc64 --- /dev/null +++ b/homeassistant/components/anthropic/coordinator.py @@ -0,0 +1,113 @@ +"""Coordinator for the Anthropic integration.""" + +from __future__ import annotations + +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 00000000000000..d5985f10b1deac --- /dev/null +++ b/homeassistant/components/anthropic/diagnostics.py @@ -0,0 +1,64 @@ +"""Diagnostics support for Anthropic.""" + +from __future__ import annotations + +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 38a99cc39d9486..bd2782c3eb195f 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 @@ 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 @@ 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.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 TextEditorCodeExecutionToolResultContentParam, + 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.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 @@ 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 @@ def delete_empty(self) -> None: ] -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", @@ -401,18 +453,16 @@ def _convert_content( messages[-1]["content"] = messages[-1]["content"][0]["text"] else: # Note: We don't pass SystemContent here as it's passed to the API as the prompt - raise HomeAssistantError("Unexpected content type in chat log") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unexpected_chat_log_content", + translation_placeholders={"type": type(content).__name__}, + ) 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: @@ -442,198 +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("Expected a stream of messages") - - 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 - - async for response in stream: - LOGGER.debug("Received response: %s", response) - - 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()), + + 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 + + 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 def __anext__( + self, + ) -> ( + conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict + ): + """Get the next item from the stream.""" + while True: + if self._buffer: + return self._buffer.popleft() + + 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("Potential policy violation detected") - elif isinstance(response, RawMessageStopEvent): - if content_details: - content_details.delete_empty() - yield {"native": content_details} - content_details = ContentDetails() - content_details.add_citation_detail() - - -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, + ) + 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 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 @@ -641,80 +872,101 @@ 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): - raise HomeAssistantError("First message must be a system message") - - # System prompt with caching enabled - system_prompt: list[TextBlockParam] = [ - TextBlockParam( - type="text", - text=system.content, - cache_control={"type": "ephemeral"}, + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="system_message_not_found" ) - ] 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] = [] @@ -724,21 +976,40 @@ async def _async_handle_chat_log( 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, ""), @@ -754,7 +1025,7 @@ async def _async_handle_chat_log( last_message = messages[-1] if last_message["role"] != "user": raise HomeAssistantError( - "Last message must be a user message to add attachments" + translation_domain=DOMAIN, translation_key="user_message_not_found" ) if isinstance(last_message["content"], str): last_message["content"] = [ @@ -762,12 +1033,17 @@ async def _async_handle_chat_log( ] 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())[ @@ -831,11 +1107,37 @@ async def _async_handle_chat_log( ), ) ) + 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): @@ -847,7 +1149,7 @@ async def _async_handle_chat_log( 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, @@ -855,23 +1157,44 @@ async def _async_handle_chat_log( ) ] ) - 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( - "Authentication error with Anthropic API, reauthentication required" + 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( - f"Sorry, I had a problem talking to Anthropic: {err}" + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={ + "message": err.message + if isinstance(err, anthropic.APIError) + else str(err) + }, ) 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. @@ -883,15 +1206,36 @@ def append_files_to_content() -> Iterable[ImageBlockParam | DocumentBlockParam]: for file_path, mime_type in files: if not file_path.exists(): - raise HomeAssistantError(f"`{file_path}` does not exist") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="wrong_file_path", + translation_placeholders={"file_path": file_path.as_posix()}, + ) 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( - "Only images and PDF are supported by the Anthropic API," - f"`{file_path}` is not an image file or PDF" + 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": mime_type = "image/jpeg" diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json index 7ed34c517d1248..7009805e9bed6d 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 37f605b1532a88..28d2c0999fcedb 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: | @@ -59,17 +59,11 @@ rules: status: exempt comment: | No data updates. - docs-examples: - status: todo - comment: | - To give examples of how people use the integration + 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 @@ -87,8 +81,11 @@ rules: status: exempt comment: | No entities disabled by default. - entity-translations: todo - exception-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 repair-issues: done diff --git a/homeassistant/components/anthropic/repairs.py b/homeassistant/components/anthropic/repairs.py index 4594967d379570..622df71c109e5f 100644 --- a/homeassistant/components/anthropic/repairs.py +++ b/homeassistant/components/anthropic/repairs.py @@ -5,6 +5,8 @@ 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 +20,8 @@ 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 +60,11 @@ async def async_step_init( 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 +106,26 @@ async def async_step_init( "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 +133,7 @@ def _iter_deprecated_subentries(self) -> Iterator[tuple[str, str]]: 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 +160,7 @@ async def _async_next_target( 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 @@ -161,7 +180,9 @@ def _async_update_current_subentry(self, user_input: dict[str, str]) -> None: is None or (subentry := entry.subentries.get(self._current_subentry_id)) is None ): - raise HomeAssistantError("Subentry not found") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="subentry_not_found" + ) updated_data = { **subentry.data, @@ -190,4 +211,6 @@ async def async_create_fix_flow( """Create flow.""" if issue_id == "model_deprecated": return ModelDeprecatedRepairFlow() - raise HomeAssistantError("Unknown issue ID") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="unknown_issue_id" + ) diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 4e34085a09c7fc..b74314bb5372fe 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" @@ -149,6 +167,47 @@ } } }, + "exceptions": { + "api_authentication_error": { + "message": "Authentication error with Anthropic API: {message}. Reauthentication required." + }, + "api_error": { + "message": "Anthropic API error: {message}." + }, + "api_refusal": { + "message": "Potential policy violation detected." + }, + "json_parse_error": { + "message": "Error with Claude structured response." + }, + "response_not_found": { + "message": "Last content in chat log is not an AssistantContent." + }, + "subentry_not_found": { + "message": "Subentry not found." + }, + "system_message_not_found": { + "message": "First message must be a system message." + }, + "unexpected_chat_log_content": { + "message": "Unexpected content type in chat log: {type}." + }, + "unexpected_stream_object": { + "message": "Expected a stream of messages." + }, + "unknown_issue_id": { + "message": "Unknown issue ID." + }, + "user_message_not_found": { + "message": "Last message must be a user message to add attachments." + }, + "wrong_file_path": { + "message": "`{file_path}` does not exist." + }, + "wrong_file_type": { + "message": "The {model} model does not support {mime_type} file types (for `{file_path}`)." + } + }, "issues": { "model_deprecated": { "fix_flow": { @@ -160,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" } } @@ -169,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/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index 09b11f555cf8e1..0e2914a0eaa83f 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -30,9 +30,10 @@ ) 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 +43,12 @@ 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 +81,12 @@ 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 3bbd46083fc3bf..84560111006166 100644 --- a/homeassistant/components/apple_tv/binary_sensor.py +++ b/homeassistant/components/apple_tv/binary_sensor.py @@ -14,6 +14,8 @@ 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/const.py b/homeassistant/components/apple_tv/const.py index dd215337f1c212..eefd90eef64a9a 100644 --- a/homeassistant/components/apple_tv/const.py +++ b/homeassistant/components/apple_tv/const.py @@ -9,3 +9,5 @@ SIGNAL_CONNECTED = "apple_tv_connected" SIGNAL_DISCONNECTED = "apple_tv_disconnected" + +ATTR_TEXT = "text" diff --git a/homeassistant/components/apple_tv/icons.json b/homeassistant/components/apple_tv/icons.json index 8acb855e3c7eaa..96aec31cc77cba 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/services.py b/homeassistant/components/apple_tv/services.py new file mode 100644 index 00000000000000..cdf659796daee1 --- /dev/null +++ b/homeassistant/components/apple_tv/services.py @@ -0,0 +1,130 @@ +"""Define services for the Apple TV integration.""" + +from __future__ import annotations + +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 00000000000000..ce2914e4d0ee72 --- /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 98ff4b9acb7989..c8da75fb1e2d29 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 ac0e92b37146b2..869148904fdb5c 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -155,7 +155,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 +346,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/aquacell/entity.py b/homeassistant/components/aquacell/entity.py index 6c746ded24cb69..8d4fea5d39d09e 100644 --- a/homeassistant/components/aquacell/entity.py +++ b/homeassistant/components/aquacell/entity.py @@ -28,7 +28,7 @@ def __init__( 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 2d8b80f4488c73..41dff9b9f6826d 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 58d3548284e3b6..0571736fdbe8bf 100644 --- a/homeassistant/components/aquacell/sensor.py +++ b/homeassistant/components/aquacell/sensor.py @@ -38,39 +38,39 @@ class SoftenerSensorEntityDescription(SensorEntityDescription): 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 +82,7 @@ class SoftenerSensorEntityDescription(SensorEntityDescription): 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/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index df088738a649a6..f389bc55a2b98b 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -2,8 +2,8 @@ import asyncio from asyncio import timeout +from contextlib import AsyncExitStack import logging -from typing import Any from arcam.fmj import ConnectionFailed from arcam.fmj.client import Client @@ -54,36 +54,31 @@ async def _run_client( client = runtime_data.client coordinators = runtime_data.coordinators - def _listen(_: Any) -> None: - for coordinator in coordinators.values(): - coordinator.async_notify_data_updated() - while True: try: - async with timeout(interval): - await client.start() - - _LOGGER.debug("Client connected %s", client.host) + async with AsyncExitStack() as stack: + async with timeout(interval): + await client.start() + stack.push_async_callback(client.stop) - try: - for coordinator in coordinators.values(): - await coordinator.state.start() + _LOGGER.debug("Client connected %s", client.host) - with client.listen(_listen): + try: for coordinator in coordinators.values(): - coordinator.async_notify_connected() - await client.process() - finally: - await client.stop() + await stack.enter_async_context( + coordinator.async_monitor_client() + ) - _LOGGER.debug("Client disconnected %s", client.host) - for coordinator in coordinators.values(): - coordinator.async_notify_disconnected() + await client.process() + finally: + _LOGGER.debug("Client disconnected %s", client.host) except ConnectionFailed: - await asyncio.sleep(interval) + pass except TimeoutError: continue except Exception: _LOGGER.exception("Unexpected exception, aborting arcam client") return + + await asyncio.sleep(interval) diff --git a/homeassistant/components/arcam_fmj/coordinator.py b/homeassistant/components/arcam_fmj/coordinator.py index 83faef37d10f4c..39b3f28fc684e4 100644 --- a/homeassistant/components/arcam_fmj/coordinator.py +++ b/homeassistant/components/arcam_fmj/coordinator.py @@ -2,11 +2,13 @@ from __future__ import annotations +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager from dataclasses import dataclass import logging from arcam.fmj import ConnectionFailed -from arcam.fmj.client import Client +from arcam.fmj.client import AmxDuetResponse, Client, ResponsePacket from arcam.fmj.state import State from homeassistant.config_entries import ConfigEntry @@ -51,7 +53,7 @@ def __init__( ) self.client = client self.state = State(client, zone) - self.last_update_success = False + self.update_in_progress = False name = config_entry.title unique_id = config_entry.unique_id or config_entry.entry_id @@ -74,24 +76,34 @@ def __init__( async def _async_update_data(self) -> None: """Fetch data for manual refresh.""" try: + self.update_in_progress = True await self.state.update() except ConnectionFailed as err: raise UpdateFailed( f"Connection failed during update for zone {self.state.zn}" ) from err + finally: + self.update_in_progress = False @callback - def async_notify_data_updated(self) -> None: - """Notify that new data has been received from the device.""" - self.async_set_updated_data(None) + def _async_notify_packet(self, packet: ResponsePacket | AmxDuetResponse) -> None: + """Packet callback to detect changes to state.""" + if ( + not isinstance(packet, ResponsePacket) + or packet.zn != self.state.zn + or self.update_in_progress + ): + return - @callback - def async_notify_connected(self) -> None: - """Handle client connected.""" - self.hass.async_create_task(self.async_refresh()) - - @callback - def async_notify_disconnected(self) -> None: - """Handle client disconnected.""" - self.last_update_success = False self.async_update_listeners() + + @asynccontextmanager + async def async_monitor_client(self) -> AsyncGenerator[None]: + """Monitor a client and state for changes while connected.""" + async with self.state: + self.hass.async_create_task(self.async_refresh()) + try: + with self.client.listen(self._async_notify_packet): + yield + finally: + self.hass.async_create_task(self.async_refresh()) diff --git a/homeassistant/components/arcam_fmj/entity.py b/homeassistant/components/arcam_fmj/entity.py index 6d635a5f1c5048..cf97ef32c38649 100644 --- a/homeassistant/components/arcam_fmj/entity.py +++ b/homeassistant/components/arcam_fmj/entity.py @@ -26,3 +26,8 @@ def __init__( if description is not None: self._attr_unique_id = f"{self._attr_unique_id}-{description.key}" self.entity_description = description + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.client.connected diff --git a/homeassistant/components/arcam_fmj/sensor.py b/homeassistant/components/arcam_fmj/sensor.py index a415f92864a098..03dacd54045384 100644 --- a/homeassistant/components/arcam_fmj/sensor.py +++ b/homeassistant/components/arcam_fmj/sensor.py @@ -4,8 +4,9 @@ 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 +22,25 @@ 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 +95,9 @@ class ArcamFmjSensorEntityDescription(SensorEntityDescription): 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,11 +107,10 @@ class ArcamFmjSensorEntityDescription(SensorEntityDescription): 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 - and vp.colorspace is not None else None ), ), @@ -100,24 +119,16 @@ class ArcamFmjSensorEntityDescription(SensorEntityDescription): 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/arris_tg2492lg/__init__.py b/homeassistant/components/arris_tg2492lg/__init__.py index c08ddcba48fc50..b5247e5e7d2192 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/aruba/__init__.py b/homeassistant/components/aruba/__init__.py index cd52f7310f308e..14c0b67967e328 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/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 67aa14f04d7f20..312c66e7d9a20b 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -945,7 +945,10 @@ async def speech_to_text( 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_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 62dcb8c1d80024..dfc40551da04b0 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -13,11 +13,12 @@ ) 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 eeb7f02b913cd3..0af45c93f0f208 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/strings.json b/homeassistant/components/assist_satellite/strings.json index f2d32336ef68ec..ff6aef845fe7c9 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_idle": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::condition_for_name%]" } }, "name": "Satellite is idle" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::condition_for_name%]" } }, "name": "Satellite is listening" @@ -27,6 +35,9 @@ "fields": { "behavior": { "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::condition_for_name%]" } }, "name": "Satellite is processing" @@ -36,6 +47,9 @@ "fields": { "behavior": { "name": "[%key:component::assist_satellite::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::condition_for_name%]" } }, "name": "Satellite is responding" @@ -58,19 +72,6 @@ "id": "Answer ID", "sentences": "Sentences" } - }, - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } } }, "services": { @@ -160,6 +161,9 @@ "fields": { "behavior": { "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::trigger_for_name%]" } }, "name": "Satellite became idle" @@ -169,6 +173,9 @@ "fields": { "behavior": { "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::trigger_for_name%]" } }, "name": "Satellite started listening" @@ -178,6 +185,9 @@ "fields": { "behavior": { "name": "[%key:component::assist_satellite::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::assist_satellite::common::trigger_for_name%]" } }, "name": "Satellite started processing" @@ -187,6 +197,9 @@ "fields": { "behavior": { "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 0adc9e587a6e06..57769c93962781 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 6f8b3d723ad7ba..18f512d2f21906 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/aurora_abb_powerone/config_flow.py b/homeassistant/components/aurora_abb_powerone/config_flow.py index 0b6e41257fcae8..2be9782819f474 100644 --- a/homeassistant/components/aurora_abb_powerone/config_flow.py +++ b/homeassistant/components/aurora_abb_powerone/config_flow.py @@ -7,9 +7,9 @@ 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 +57,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 +89,7 @@ async def async_step_user( 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 8d33cc95d458ca..04728cbb47d09f 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/auth/__init__.py b/homeassistant/components/auth/__init__.py index 33aeb283f5a05c..974cd8212865e5 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -157,7 +157,6 @@ 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 +172,6 @@ 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/login_flow.py b/homeassistant/components/auth/login_flow.py index 235d5b4c338f18..12d108f7942c25 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -142,6 +142,13 @@ async def get(self, request: web.Request) -> web.Response: "authorization_endpoint": f"{url_prefix}/auth/authorize", "token_endpoint": f"{url_prefix}/auth/token", "revocation_endpoint": f"{url_prefix}/auth/revoke", + # Home Assistant already accepts URL-based client_ids via + # IndieAuth without prior registration, which is compatible with + # draft-ietf-oauth-client-id-metadata-document. This flag + # advertises that support to encourage clients to use it. The + # metadata document is not actually fetched as IndieAuth doesn't + # require it. + "client_id_metadata_document_supported": True, "response_types_supported": ["code"], "service_documentation": ( "https://developers.home-assistant.io/docs/auth_api" diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index 5b4a539b86f8b2..eb427dfc06798c 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -15,24 +15,6 @@ 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 +55,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 +111,9 @@ async def async_setup_flow(msg: dict[str, Any]) -> None: @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 8887674dcdbaef..6d2d2eec8374c6 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -4,10 +4,10 @@ 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 +83,6 @@ 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 +142,7 @@ "occupancy", "person", "power", + "remote", "schedule", "select", "siren", @@ -150,6 +150,8 @@ "temperature", "text", "timer", + "todo", + "update", "vacuum", "valve", "water_heater", @@ -167,6 +169,7 @@ "cover", "device_tracker", "door", + "doorbell", "event", "fan", "garage_door", @@ -191,6 +194,7 @@ "switch", "temperature", "text", + "timer", "todo", "update", "vacuum", @@ -226,16 +230,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 +833,7 @@ async def async_trigger( 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 +901,15 @@ def started_action() -> None: 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 +1282,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/autoskope/__init__.py b/homeassistant/components/autoskope/__init__.py index a269976dc3503a..084755cb5487f9 100644 --- a/homeassistant/components/autoskope/__init__.py +++ b/homeassistant/components/autoskope/__init__.py @@ -8,7 +8,7 @@ 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 +31,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 3f141b4663f53f..0f30fe9ada7bd3 100644 --- a/homeassistant/components/autoskope/config_flow.py +++ b/homeassistant/components/autoskope/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from autoskope_client.api import AutoskopeApi @@ -39,12 +40,39 @@ } ) +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 +91,9 @@ async def async_step_user( 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 +106,35 @@ async def async_step_user( 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/quality_scale.yaml b/homeassistant/components/autoskope/quality_scale.yaml index c0af808b0996b4..264d9c35e7a638 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 d3a05f9f286512..18e83c0866c35a 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/aws/__init__.py b/homeassistant/components/aws/__init__.py index da84c8985f5619..c4767bd934bfc8 100644 --- a/homeassistant/components/aws/__init__.py +++ b/homeassistant/components/aws/__init__.py @@ -1,8 +1,12 @@ """Support for Amazon Web Services (AWS).""" +from __future__ import annotations + 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 +34,22 @@ 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 +100,13 @@ 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 +122,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 +154,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 c885495934f6b9..df0eb9574db9de 100644 --- a/homeassistant/components/aws/const.py +++ b/homeassistant/components/aws/const.py @@ -1,10 +1,17 @@ """Constant for AWS component.""" +from __future__ import annotations + +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 47d66900eb039a..a67b9f34893727 100644 --- a/homeassistant/components/aws/notify.py +++ b/homeassistant/components/aws/notify.py @@ -27,7 +27,7 @@ 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 +76,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 +89,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/diagnostics.py b/homeassistant/components/aws_s3/diagnostics.py index 85acf83816a9ed..3efb22158a192d 100644 --- a/homeassistant/components/aws_s3/diagnostics.py +++ b/homeassistant/components/aws_s3/diagnostics.py @@ -5,10 +5,7 @@ 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 +28,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/axis/const.py b/homeassistant/components/axis/const.py index d315214c0e7da7..2b7e7c5644ace6 100644 --- a/homeassistant/components/axis/const.py +++ b/homeassistant/components/axis/const.py @@ -18,4 +18,10 @@ 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/event.py b/homeassistant/components/axis/event.py new file mode 100644 index 00000000000000..d40aabdfc34197 --- /dev/null +++ b/homeassistant/components/axis/event.py @@ -0,0 +1,62 @@ +"""Support for Axis event entities.""" + +from __future__ import annotations + +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 2bfce19bae5bba..0229a0157696da 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/manifest.json b/homeassistant/components/axis/manifest.json index ed446f6c72ada1..ca90c2d0e03ab7 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==68"], + "requirements": ["axis==69"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json index 13d16dd596ef66..68853ecd6055c1 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/backup/http.py b/homeassistant/components/backup/http.py index b40ea76cd5924b..82571296e8cfe6 100644 --- a/homeassistant/components/backup/http.py +++ b/homeassistant/components/backup/http.py @@ -23,7 +23,7 @@ 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 +195,11 @@ async def _post(self, request: Request) -> Response: 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 a05a55bf4e93a9..118b3015b0679e 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -68,6 +68,7 @@ BackupReaderWriterError, BaseBackup, Folder, + InvalidBackupFilename, ) from .store import BackupStore from .util import ( @@ -1006,6 +1007,14 @@ async def _async_receive_backup( ) -> 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 +1025,7 @@ async def _async_receive_backup( 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( @@ -1957,10 +1966,7 @@ async def async_receive_backup( suggested_filename: str, ) -> WrittenBackup: """Receive a backup.""" - safe_filename = PureWindowsPath(suggested_filename).name - if not safe_filename or safe_filename == "..": - safe_filename = "backup.tar" - temp_file = Path(self.temp_backup_dir, safe_filename) + temp_file = Path(self.temp_backup_dir, suggested_filename) async_add_executor_job = self._hass.async_add_executor_job await async_add_executor_job(make_backup_dir, self.temp_backup_dir) diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json index ccc63073515601..e72133f1ce4ec0 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.4.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 d927cd0bac5691..c89a4137355bbd 100644 --- a/homeassistant/components/backup/models.py +++ b/homeassistant/components/backup/models.py @@ -95,6 +95,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/services.py b/homeassistant/components/backup/services.py index 17448f7bb065c1..192a2f3c171464 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/util.py b/homeassistant/components/backup/util.py index fe060521668770..cef18a7a97511e 100644 --- a/homeassistant/components/backup/util.py +++ b/homeassistant/components/backup/util.py @@ -384,9 +384,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 @@ -420,7 +423,7 @@ def __init__( hass: HomeAssistant, backup: AgentBackup, open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], - password: str | None, + password: str, ) -> None: """Initialize.""" self._workers: list[_CipherWorkerStatus] = [] @@ -432,7 +435,7 @@ def __init__( def size(self) -> int: """Return the maximum size of the decrypted or encrypted backup.""" - return get_archive_max_ciphertext_size( # type: ignore[no-any-return] + return get_archive_max_ciphertext_size( self._backup.size, SECURETAR_CREATE_VERSION, self._num_tar_files() ) diff --git a/homeassistant/components/bang_olufsen/__init__.py b/homeassistant/components/bang_olufsen/__init__.py index 1668b03b02146a..6b0f1d5f3083cf 100644 --- a/homeassistant/components/bang_olufsen/__init__.py +++ b/homeassistant/components/bang_olufsen/__init__.py @@ -21,8 +21,9 @@ 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 +59,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 +75,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/config_flow.py b/homeassistant/components/bang_olufsen/config_flow.py index 62ee08502e2bf1..2b0dc774f490da 100644 --- a/homeassistant/components/bang_olufsen/config_flow.py +++ b/homeassistant/components/bang_olufsen/config_flow.py @@ -52,6 +52,7 @@ class BeoConfigFlowHandler(ConfigFlow, domain=DOMAIN): _beolink_jid = "" _client: MozartClient + _friendly_name = "" _host = "" _model = "" _name = "" @@ -111,6 +112,7 @@ async def async_step_user( ) 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 +151,7 @@ async def async_step_zeroconf( 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 +167,13 @@ async def async_step_zeroconf( 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/event.py b/homeassistant/components/bang_olufsen/event.py index a14e940b65582c..270a51c0c64164 100644 --- a/homeassistant/components/bang_olufsen/event.py +++ b/homeassistant/components/bang_olufsen/event.py @@ -20,7 +20,6 @@ CONNECTION_STATUS, DEVICE_BUTTON_EVENTS, DOMAIN, - MANUFACTURER, BeoModel, WebsocketNotification, ) @@ -142,12 +141,6 @@ def __init__( 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/sensor.py b/homeassistant/components/bang_olufsen/sensor.py index 9ff703112c391e..04733ea6772b5a 100644 --- a/homeassistant/components/bang_olufsen/sensor.py +++ b/homeassistant/components/bang_olufsen/sensor.py @@ -115,7 +115,7 @@ def __init__(self, config_entry: BeoConfigEntry, remote: PairedRemote) -> None: 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/util.py b/homeassistant/components/bang_olufsen/util.py index 540837441a47dd..96f33dd5af1f35 100644 --- a/homeassistant/components/bang_olufsen/util.py +++ b/homeassistant/components/bang_olufsen/util.py @@ -34,7 +34,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/battery/condition.py b/homeassistant/components/battery/condition.py index 60f479aa4af27f..58d2835aa47826 100644 --- a/homeassistant/components/battery/condition.py +++ b/homeassistant/components/battery/condition.py @@ -29,14 +29,30 @@ } 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 9bd7c1f3596185..4946248ad1ba8e 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 61f1698bea4e10..078ffacae0467f 100644 --- a/homeassistant/components/battery/strings.json +++ b/homeassistant/components/battery/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -11,6 +13,9 @@ "fields": { "behavior": { "name": "[%key:component::battery::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::condition_for_name%]" } }, "name": "Battery is charging" @@ -21,6 +26,9 @@ "behavior": { "name": "[%key:component::battery::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::battery::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::battery::common::condition_threshold_name%]" } @@ -32,6 +40,9 @@ "fields": { "behavior": { "name": "[%key:component::battery::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::condition_for_name%]" } }, "name": "Battery is low" @@ -41,6 +52,9 @@ "fields": { "behavior": { "name": "[%key:component::battery::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::condition_for_name%]" } }, "name": "Battery is not charging" @@ -50,26 +64,14 @@ "fields": { "behavior": { "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": { @@ -87,6 +89,9 @@ "behavior": { "name": "[%key:component::battery::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::battery::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::battery::common::trigger_threshold_name%]" } @@ -98,6 +103,9 @@ "fields": { "behavior": { "name": "[%key:component::battery::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::trigger_for_name%]" } }, "name": "Battery low" @@ -107,6 +115,9 @@ "fields": { "behavior": { "name": "[%key:component::battery::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::trigger_for_name%]" } }, "name": "Battery not low" @@ -116,6 +127,9 @@ "fields": { "behavior": { "name": "[%key:component::battery::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::battery::common::trigger_for_name%]" } }, "name": "Battery started charging" @@ -125,6 +139,9 @@ "fields": { "behavior": { "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 426dae8256976e..3f9bcb43092ecb 100644 --- a/homeassistant/components/battery/trigger.py +++ b/homeassistant/components/battery/trigger.py @@ -32,19 +32,27 @@ } 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 2ca59cf423f380..2d6ef389b50d67 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/config_flow.py b/homeassistant/components/bayesian/config_flow.py index ce13cf43d8cade..c43305ef99882c 100644 --- a/homeassistant/components/bayesian/config_flow.py +++ b/homeassistant/components/bayesian/config_flow.py @@ -33,11 +33,13 @@ 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 .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 @@ def async_config_entry_title(self, options: Mapping[str, str]) -> str: 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/bbox/__init__.py b/homeassistant/components/bbox/__init__.py index 8c3bbf0d57f28b..ccbd46cb9a5e28 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/bitcoin/__init__.py b/homeassistant/components/bitcoin/__init__.py index cfdfb53c04434b..830541f830ea87 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/blebox/cover.py b/homeassistant/components/blebox/cover.py index c52c551bbacc61..f6aba3240c991f 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -85,7 +85,9 @@ def current_cover_position(self) -> int | None: 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/manifest.json b/homeassistant/components/blebox/manifest.json index 19a8a06c835937..885cfb81038d40 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/blink/icons.json b/homeassistant/components/blink/icons.json index 1f4edb07f42ffc..44f9c4561c17a3 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/services.py b/homeassistant/components/blink/services.py index 3882aa67312b4e..5839dc8914cbf1 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -5,15 +5,9 @@ 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 +17,10 @@ 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 244763d5535af2..82aeafb8ede30e 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 cdd30483b5084f..7bf075f18c9d31 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/blinksticklight/__init__.py b/homeassistant/components/blinksticklight/__init__.py index dd45fbcd690cbc..06375f0ab6973b 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 01e5c90aadf748..31bbe3e08bb12b 100644 --- a/homeassistant/components/blinksticklight/light.py +++ b/homeassistant/components/blinksticklight/light.py @@ -1,4 +1,4 @@ -"""Support for Blinkstick lights.""" +"""Support for BlinkStick lights.""" # mypy: ignore-errors from __future__ import annotations @@ -40,7 +40,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/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 941a7822439e01..84f02b7859ff70 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -58,6 +58,7 @@ 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 +117,7 @@ "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/api.py b/homeassistant/components/bluetooth/api.py index c0ec6acf0a5233..7c48bdedb3e71e 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -207,6 +207,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/manifest.json b/homeassistant/components/bluetooth/manifest.json index d187e749b3e6a5..ea2de94e251193 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/bond/button.py b/homeassistant/components/bond/button.py index 9cea0251b413ca..af5438599661e9 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -260,6 +260,14 @@ class BondButtonEntityDescription(ButtonEntityDescription): ), ) +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 +293,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/brands/__init__.py b/homeassistant/components/brands/__init__.py index 0cfe254904f323..e6cf7b9112f028 100644 --- a/homeassistant/components/brands/__init__.py +++ b/homeassistant/components/brands/__init__.py @@ -52,7 +52,9 @@ def _rotate_token(_now: Any) -> None: """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/braviatv/__init__.py b/homeassistant/components/braviatv/__init__.py index 1c183b397d88de..378ff8854522f2 100644 --- a/homeassistant/components/braviatv/__init__.py +++ b/homeassistant/components/braviatv/__init__.py @@ -7,9 +7,11 @@ 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 +48,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/coordinator.py b/homeassistant/components/braviatv/coordinator.py index f3d5db90e71741..dd7ee15fe08f61 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -173,6 +173,9 @@ async def _async_update_data(self) -> 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/bring/manifest.json b/homeassistant/components/bring/manifest.json index b2177acb52f0cf..b8ab566aba7bf1 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/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index 8dd6cee82cb185..6c7f398fd29467 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -1,4 +1,5 @@ """The Broadlink integration.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/broadlink/climate.py b/homeassistant/components/broadlink/climate.py index 5be04c24f0dc6f..78af781d5039fb 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 602a3693b7b355..d866e701cce3f1 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -6,7 +6,9 @@ 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 e7f4f792ab20f4..c61c2c26380998 100644 --- a/homeassistant/components/broadlink/device.py +++ b/homeassistant/components/broadlink/device.py @@ -133,6 +133,8 @@ async def async_setup(self) -> bool: 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 00000000000000..29238f08e1bd63 --- /dev/null +++ b/homeassistant/components/broadlink/infrared.py @@ -0,0 +1,69 @@ +"""Infrared platform for Broadlink remotes.""" + +from __future__ import annotations + +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 64698e572495d6..2df3cab4366ca3 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 00000000000000..31b83d5dcfb28b --- /dev/null +++ b/homeassistant/components/broadlink/radio_frequency.py @@ -0,0 +1,132 @@ +"""Radio Frequency platform for Broadlink.""" + +from __future__ import annotations + +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 4cd2cc9e06cf50..363a344a9d2bca 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 661fc62600dcab..15b182fa861a8e 100644 --- a/homeassistant/components/broadlink/select.py +++ b/homeassistant/components/broadlink/select.py @@ -31,6 +31,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 5323a08d227287..194000562a82f3 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -108,6 +108,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 a019f350ec066d..c291e7a77a0c6b 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 d6869ac4c9c180..f4301155ee99f1 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -1,4 +1,5 @@ """Support for Broadlink switches.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/broadlink/time.py b/homeassistant/components/broadlink/time.py index 4687df6b8b6aae..13904aa8ad34e8 100644 --- a/homeassistant/components/broadlink/time.py +++ b/homeassistant/components/broadlink/time.py @@ -22,6 +22,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/brother/manifest.json b/homeassistant/components/brother/manifest.json index 1f95fefc66e2e8..af519876eb8963 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 4f1a10c26213c1..cc473742b8b460 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -293,9 +293,8 @@ class BrotherSensorEntityDescription(SensorEntityDescription): ), 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 f52875018c1945..428edc25cb5637 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/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 0520cb8039eb10..1e5b641a357f67 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -13,6 +13,7 @@ Info, StaticState, ) +from yarl import URL from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -28,11 +29,16 @@ 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/climate.py b/homeassistant/components/bsblan/climate.py index 8ae03e0a7a2ee6..fc8dbd4ff55b27 100644 --- a/homeassistant/components/bsblan/climate.py +++ b/homeassistant/components/bsblan/climate.py @@ -4,7 +4,7 @@ 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 +24,7 @@ from . import BSBLanConfigEntry, BSBLanData from .const import ATTR_TARGET_TEMPERATURE, DOMAIN -from .entity import BSBLanEntity +from .entity import BSBLanCircuitEntity PARALLEL_UPDATES = 1 @@ -63,10 +63,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 +86,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" - - # Set temperature range if available, otherwise use Home Assistant defaults - if (static := data.static) is not None: + super().__init__(data.fast_coordinator, data, circuit) + self._circuit = circuit + mac = format_mac(data.device.MAC) + + # 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 +143,7 @@ def hvac_mode(self) -> HVACMode | None: @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 +183,7 @@ async def async_set_data(self, **kwargs: Any) -> None: 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 01024a07e42c9e..9ff633b048078e 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -15,19 +15,21 @@ 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 +79,7 @@ async def async_step_zeroconf( # 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 +125,8 @@ async def async_step_discovery_confirm( ) 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 +141,7 @@ async def _validate_and_create( """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 +235,12 @@ async def async_step_reconfigure( # 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 +324,14 @@ def _show_setup_form( 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 +349,7 @@ async def _get_bsblan_info( 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 +371,27 @@ async def _get_bsblan_info( 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 8dfdc180089da4..669db12dc9c032 100644 --- a/homeassistant/components/bsblan/const.py +++ b/homeassistant/components/bsblan/const.py @@ -22,5 +22,6 @@ 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 a2805aa5ff13d8..6ba18827fa97eb 100644 --- a/homeassistant/components/bsblan/coordinator.py +++ b/homeassistant/components/bsblan/coordinator.py @@ -49,7 +49,7 @@ class BSBLanFastData: """BSBLan fast-polling data.""" - state: State + states: dict[int, State] sensor: Sensor dhw: HotWaterState | None = None @@ -94,6 +94,7 @@ def __init__( hass: HomeAssistant, config_entry: BSBLanConfigEntry, client: BSBLAN, + circuits: list[int], ) -> None: """Initialize the BSB-LAN fast coordinator.""" super().__init__( @@ -103,14 +104,19 @@ def __init__( 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) except BSBLANAuthError as err: @@ -140,7 +146,7 @@ async def _async_update_data(self) -> BSBLanFastData: ) return BSBLanFastData( - state=state, + states=states, sensor=sensor, dhw=dhw, ) diff --git a/homeassistant/components/bsblan/diagnostics.py b/homeassistant/components/bsblan/diagnostics.py index 324e2fc1497cd5..66e5e97172c65c 100644 --- a/homeassistant/components/bsblan/diagnostics.py +++ b/homeassistant/components/bsblan/diagnostics.py @@ -20,13 +20,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() 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 536551fe6d026e..d2d1c271d35375 100644 --- a/homeassistant/components/bsblan/entity.py +++ b/homeassistant/components/bsblan/entity.py @@ -2,17 +2,11 @@ from __future__ import annotations -from yarl import URL - from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.helpers.device_registry import ( - CONNECTION_NETWORK_MAC, - DeviceInfo, - format_mac, -) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import BSBLanData +from . import BSBLanData, get_bsblan_device_info from .const import DEFAULT_PORT, DOMAIN from .coordinator import BSBLanCoordinator, BSBLanFastCoordinator, BSBLanSlowCoordinator @@ -27,28 +21,8 @@ def __init__(self, coordinator: _T, data: BSBLanData) -> None: super().__init__(coordinator) host = coordinator.config_entry.data[CONF_HOST] port = coordinator.config_entry.data.get(CONF_PORT, DEFAULT_PORT) - 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=str(URL.build(scheme="http", host=host, port=port)), + self._attr_device_info = get_bsblan_device_info( + data.device, data.info, host, port ) @@ -60,6 +34,32 @@ def __init__(self, coordinator: BSBLanFastCoordinator, data: BSBLanData) -> None 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.""" @@ -80,3 +80,28 @@ async def async_added_to_hass(self) -> None: 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/manifest.json b/homeassistant/components/bsblan/manifest.json index 6da7ab41aeae12..4b65dca61b597e 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.4"], + "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 be9efefd13735f..f309f63765cefb 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/strings.json b/homeassistant/components/bsblan/strings.json index bd663eb8ba7f92..d257119f2a5ada 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 c91a9518f7b9a2..4f11a52b03d5ff 100644 --- a/homeassistant/components/bsblan/water_heater.py +++ b/homeassistant/components/bsblan/water_heater.py @@ -21,7 +21,7 @@ from . import BSBLanConfigEntry, BSBLanData from .const import DOMAIN -from .entity import BSBLanDualCoordinatorEntity +from .entity import BSBLanWaterHeaterDeviceEntity PARALLEL_UPDATES = 1 @@ -61,7 +61,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 diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 032bd2fd36d454..9af145cfef8f4b 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -15,8 +15,12 @@ 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 +35,9 @@ 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 +81,7 @@ 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 +326,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 +524,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 @@ -578,13 +596,17 @@ def state(self) -> str: return STATE_OFF @callback - def async_write_ha_state(self) -> None: + def _async_write_ha_state(self) -> None: """Write the state to the state machine. This sets up listeners to handle state transitions for start or end of the current or upcoming event. """ - super().async_write_ha_state() + 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 +647,13 @@ def update(_: datetime.datetime) -> None: 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 +662,87 @@ async def async_will_remove_from_hass(self) -> None: 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 +789,10 @@ def __init__(self, component: EntityComponent[CalendarEntity]) -> None: 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 +844,14 @@ def __init__(self, component: EntityComponent[CalendarEntity]) -> None: 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 +871,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 +913,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 +960,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 +993,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 7452e7ec7fe230..40c06cb88bc7f3 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/strings.json b/homeassistant/components/calendar/strings.json index 1175002adc8441..2f5d666904e9f6 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -1,6 +1,7 @@ { "common": { - "condition_behavior_name": "Condition passes if" + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least" }, "conditions": { "is_event_active": { @@ -8,6 +9,9 @@ "fields": { "behavior": { "name": "[%key:component::calendar::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::calendar::common::condition_for_name%]" } }, "name": "Calendar event is active" @@ -60,12 +64,6 @@ } }, "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, "trigger_offset_type": { "options": { "after": "After", diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 364c63c2c5fda1..be9ff1a9e11ede 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -58,7 +58,6 @@ 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 +162,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 +210,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 +244,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: @@ -760,12 +755,12 @@ def camera_capabilities(self) -> CameraCapabilities: return CameraCapabilities(frontend_stream_types) @callback - def async_write_ha_state(self) -> None: + def _async_write_ha_state(self) -> None: """Write the state to the state machine. Schedules async_refresh_providers if support of streams have changed. """ - super().async_write_ha_state() + super()._async_write_ha_state() if self.__supports_stream != ( supports_stream := self.supported_features & CameraEntityFeature.STREAM ): @@ -931,6 +926,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/manifest.json b/homeassistant/components/camera/manifest.json index 72ccfd5b02e7bf..31091828d0f2b5 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/casper_glow/__init__.py b/homeassistant/components/casper_glow/__init__.py index 4d1494d9d17370..e4e114fd245587 100644 --- a/homeassistant/components/casper_glow/__init__.py +++ b/homeassistant/components/casper_glow/__init__.py @@ -11,7 +11,13 @@ from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.LIGHT, + Platform.SELECT, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool: diff --git a/homeassistant/components/casper_glow/binary_sensor.py b/homeassistant/components/casper_glow/binary_sensor.py index 9da8bcfe984e3b..0180ccbcc6e9f9 100644 --- a/homeassistant/components/casper_glow/binary_sensor.py +++ b/homeassistant/components/casper_glow/binary_sensor.py @@ -4,7 +4,11 @@ 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 +25,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 +55,34 @@ async def async_added_to_hass(self) -> None: @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/const.py b/homeassistant/components/casper_glow/const.py index 37b5b7656ff249..c7e8d86729bb36 100644 --- a/homeassistant/components/casper_glow/const.py +++ b/homeassistant/components/casper_glow/const.py @@ -12,5 +12,7 @@ DEFAULT_DIMMING_TIME_MINUTES: int = DIMMING_TIME_MINUTES[0] +DIMMING_TIME_OPTIONS: tuple[str, ...] = tuple(str(m) for m in DIMMING_TIME_MINUTES) + # Interval between periodic state polls to catch externally-triggered changes. STATE_POLL_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/casper_glow/coordinator.py b/homeassistant/components/casper_glow/coordinator.py index 6b363d0445bacc..576dfeda11e8e1 100644 --- a/homeassistant/components/casper_glow/coordinator.py +++ b/homeassistant/components/casper_glow/coordinator.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from .const import STATE_POLL_INTERVAL +from .const import SORTED_BRIGHTNESS_LEVELS, STATE_POLL_INTERVAL _LOGGER = logging.getLogger(__name__) @@ -51,6 +51,15 @@ def __init__( ) self.title = title + # The device API couples brightness and dimming time into a + # single command (set_brightness_and_dimming_time), so both + # values must be tracked here for cross-entity use. + self.last_brightness_pct: int = ( + device.state.brightness_level + if device.state.brightness_level is not None + else SORTED_BRIGHTNESS_LEVELS[0] + ) + @callback def _needs_poll( self, diff --git a/homeassistant/components/casper_glow/diagnostics.py b/homeassistant/components/casper_glow/diagnostics.py new file mode 100644 index 00000000000000..d581b74808131a --- /dev/null +++ b/homeassistant/components/casper_glow/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics support for the Casper Glow integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components import bluetooth +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import CasperGlowConfigEntry + +SERVICE_INFO_TO_REDACT = frozenset({"address", "name", "source", "device"}) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: CasperGlowConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + + service_info = bluetooth.async_last_service_info( + hass, coordinator.device.address, connectable=True + ) + + return { + "service_info": async_redact_data( + service_info.as_dict() if service_info else None, + SERVICE_INFO_TO_REDACT, + ), + } diff --git a/homeassistant/components/casper_glow/icons.json b/homeassistant/components/casper_glow/icons.json index c291e1abc22345..6d8c1d8347450d 100644 --- a/homeassistant/components/casper_glow/icons.json +++ b/homeassistant/components/casper_glow/icons.json @@ -12,6 +12,11 @@ "resume": { "default": "mdi:play" } + }, + "select": { + "dimming_time": { + "default": "mdi:timer-outline" + } } } } diff --git a/homeassistant/components/casper_glow/light.py b/homeassistant/components/casper_glow/light.py index a8e29b2a7a3c24..686ccee4a7d77a 100644 --- a/homeassistant/components/casper_glow/light.py +++ b/homeassistant/components/casper_glow/light.py @@ -71,6 +71,7 @@ def _update_from_state(self, state: GlowState) -> None: self._attr_color_mode = ColorMode.BRIGHTNESS if state.brightness_level is not None: self._attr_brightness = _device_pct_to_ha_brightness(state.brightness_level) + self.coordinator.last_brightness_pct = state.brightness_level @callback def _async_handle_state_update(self, state: GlowState) -> None: @@ -97,6 +98,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) ) self._attr_brightness = _device_pct_to_ha_brightness(brightness_pct) + self.coordinator.last_brightness_pct = brightness_pct async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" diff --git a/homeassistant/components/casper_glow/manifest.json b/homeassistant/components/casper_glow/manifest.json index 83b2a3a2f430cc..1e862beae69b27 100644 --- a/homeassistant/components/casper_glow/manifest.json +++ b/homeassistant/components/casper_glow/manifest.json @@ -15,5 +15,5 @@ "iot_class": "local_polling", "loggers": ["pycasperglow"], "quality_scale": "silver", - "requirements": ["pycasperglow==1.1.0"] + "requirements": ["pycasperglow==1.2.0"] } diff --git a/homeassistant/components/casper_glow/quality_scale.yaml b/homeassistant/components/casper_glow/quality_scale.yaml index 7f73eb17602398..5e2053ed86f56d 100644 --- a/homeassistant/components/casper_glow/quality_scale.yaml +++ b/homeassistant/components/casper_glow/quality_scale.yaml @@ -39,7 +39,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: No network discovery. @@ -51,16 +51,24 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: todo - dynamic-devices: todo - entity-category: todo - entity-device-class: todo - entity-disabled-by-default: todo + dynamic-devices: + status: exempt + 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 new file mode 100644 index 00000000000000..61d1446a9d34cd --- /dev/null +++ b/homeassistant/components/casper_glow/select.py @@ -0,0 +1,92 @@ +"""Casper Glow integration select platform for dimming time.""" + +from __future__ import annotations + +from pycasperglow import GlowState + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import DIMMING_TIME_OPTIONS +from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator +from .entity import CasperGlowEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CasperGlowConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the select platform for Casper Glow.""" + async_add_entities([CasperGlowDimmingTimeSelect(entry.runtime_data)]) + + +class CasperGlowDimmingTimeSelect(CasperGlowEntity, SelectEntity, RestoreEntity): + """Select entity for Casper Glow dimming time.""" + + _attr_translation_key = "dimming_time" + _attr_entity_category = EntityCategory.CONFIG + _attr_options = list(DIMMING_TIME_OPTIONS) + _attr_unit_of_measurement = UnitOfTime.MINUTES + + def __init__(self, coordinator: CasperGlowCoordinator) -> None: + """Initialize the dimming time select entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{format_mac(coordinator.device.address)}_dimming_time" + + @property + def current_option(self) -> str | None: + """Return the currently selected dimming time from the coordinator.""" + if self.coordinator.last_dimming_time_minutes is None: + return None + return str(self.coordinator.last_dimming_time_minutes) + + async def async_added_to_hass(self) -> None: + """Restore last known dimming time and register state update callback.""" + await super().async_added_to_hass() + if self.coordinator.last_dimming_time_minutes is None and ( + last_state := await self.async_get_last_state() + ): + if last_state.state in DIMMING_TIME_OPTIONS: + self.coordinator.last_dimming_time_minutes = int(last_state.state) + 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.brightness_level is not None: + self.coordinator.last_brightness_pct = state.brightness_level + if ( + state.configured_dimming_time_minutes is not None + and self.coordinator.last_dimming_time_minutes is None + ): + self.coordinator.last_dimming_time_minutes = ( + state.configured_dimming_time_minutes + ) + # Dimming time is not part of the device state + # that is provided via BLE update. Therefore + # we need to trigger a state update for the select entity + # to update the current state. + self.async_write_ha_state() + + async def async_select_option(self, option: str) -> None: + """Set the dimming time.""" + await self._async_command( + self._device.set_brightness_and_dimming_time( + self.coordinator.last_brightness_pct, int(option) + ) + ) + self.coordinator.last_dimming_time_minutes = int(option) + # Dimming time is not part of the device state + # that is provided via BLE update. Therefore + # we need to trigger a state update for the select entity + # to update the current state. + self.async_write_ha_state() diff --git a/homeassistant/components/casper_glow/sensor.py b/homeassistant/components/casper_glow/sensor.py new file mode 100644 index 00000000000000..820fcc4b3fff2c --- /dev/null +++ b/homeassistant/components/casper_glow/sensor.py @@ -0,0 +1,134 @@ +"""Casper Glow integration sensor platform.""" + +from __future__ import annotations + +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 a9d70090170015..afe72eb0c01deb 100644 --- a/homeassistant/components/casper_glow/strings.json +++ b/homeassistant/components/casper_glow/strings.json @@ -39,6 +39,16 @@ "resume": { "name": "Resume dimming" } + }, + "select": { + "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 e72eb196b61234..bf21885cc21ec3 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -1,4 +1,5 @@ """Component to embed Google Cast.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index 2948c30fd1a19e..71967282833cd3 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -65,6 +65,8 @@ def fill_out_missing_chromecast_info(self, hass: HomeAssistant) -> ChromecastInf """ 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/media_player.py b/homeassistant/components/cast/media_player.py index 6acbb068953ec0..ed27322360df43 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -1,4 +1,5 @@ """Provide functionality to interact with Cast devices on the network.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json index 2f5a0e2875669d..fd8b4382e06fee 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/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py index d77a9ab9dda2d8..e7bcec16ae77d1 100644 --- a/homeassistant/components/citybikes/sensor.py +++ b/homeassistant/components/citybikes/sensor.py @@ -50,7 +50,9 @@ 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 +240,6 @@ async def async_update(self) -> None: 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/climate/condition.py b/homeassistant/components/climate/condition.py index 0d1b5803b599d7..449996bb82952d 100644 --- a/homeassistant/components/climate/condition.py +++ b/homeassistant/components/climate/condition.py @@ -13,8 +13,8 @@ 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 @@ def _get_entity_unit(self, entity_state: State) -> str | None: "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 cb1e09abac07c3..915cb7fcc9adac 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/strings.json b/homeassistant/components/climate/strings.json index 2c2947c15ee28c..5c45a31f2c3ae9 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -1,92 +1,118 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { "is_cooling": { - "description": "Tests if one or more climate-control devices are cooling.", + "description": "Tests if one or more thermostats are cooling.", "fields": { "behavior": { "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": { "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": { "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": { "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": { "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": { "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": { "name": "[%key:component::climate::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::climate::common::condition_for_name%]" + }, "threshold": { "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": { "name": "[%key:component::climate::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::climate::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::climate::common::condition_threshold_name%]" } }, - "name": "Climate-control device target temperature" + "name": "Thermostat target temperature" } }, "device_automation": { @@ -239,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." @@ -266,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.", @@ -362,122 +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": { "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": { "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": { "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": { "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": { "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": { "name": "[%key:component::climate::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::climate::common::trigger_for_name%]" + }, "threshold": { "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": { "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": { "name": "[%key:component::climate::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::climate::common::trigger_for_name%]" + }, "threshold": { "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": { "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": { "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 9f9f02d70710fd..26c074e8b85fef 100644 --- a/homeassistant/components/climate/trigger.py +++ b/homeassistant/components/climate/trigger.py @@ -8,14 +8,15 @@ 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 @@ class ClimateTargetTemperatureCrossedThresholdTrigger( "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 a112be840957a8..8bb7513c8ceac6 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 17a1ad4800df6b..d1de4c55eeb7d3 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -36,7 +36,7 @@ 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 +181,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 +190,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 +205,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 +227,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 +241,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 +267,6 @@ def _handle_cloudhooks_updated(cloudhooks: dict[str, Any]) -> None: ) -@bind_hass @callback def async_remote_ui_url(hass: HomeAssistant) -> str: """Get the remote UI URL.""" diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index b1c3bebcaaefa1..51bc0bd4e39f06 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -374,6 +374,7 @@ async def async_webhook_message(self, payload: dict[Any, Any]) -> dict[Any, Any] 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/http_api.py b/homeassistant/components/cloud/http_api.py index 53ed41d5b6d816..ccabc63092ac7e 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -615,6 +615,7 @@ def get_domain_table_markdown(domain_info: dict[str, Any]) -> str: return markdown + @require_admin async def get(self, request: web.Request) -> web.Response: """Download support package file.""" @@ -709,6 +710,7 @@ def with_cloud_auth( 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 +752,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 +812,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 +833,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/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py index d512ebc4f3d34e..3d033f0805c090 100644 --- a/homeassistant/components/comelit/binary_sensor.py +++ b/homeassistant/components/comelit/binary_sensor.py @@ -1,15 +1,18 @@ -"""Support for sensors.""" +"""Support for binary sensors.""" from __future__ import annotations -from typing import TYPE_CHECKING, cast +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final, 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 +26,68 @@ 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 +101,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 +128,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 84761a89722474..3f5a5268bb9371 100644 --- a/homeassistant/components/comelit/climate.py +++ b/homeassistant/components/comelit/climate.py @@ -9,7 +9,6 @@ from aiocomelit.const import CLIMATE from homeassistant.components.climate import ( - DOMAIN as CLIMATE_DOMAIN, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -92,7 +91,7 @@ async def async_setup_entry( entities: list[ClimateEntity] = [] for device in coordinator.data[CLIMATE].values(): - values = load_api_data(device, CLIMATE_DOMAIN) + values = load_api_data(device, "climate") if values[0] == 0 and values[4] == 0: # No climate data, device is only a humidifier/dehumidifier @@ -140,7 +139,7 @@ def __init__( def _update_attributes(self) -> None: """Update class attributes.""" device = self.coordinator.data[CLIMATE][self._device.index] - values = load_api_data(device, CLIMATE_DOMAIN) + values = load_api_data(device, "climate") _active = values[1] _mode = values[2] # Values from API: "O", "L", "U" diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 009d864c0cb2a0..a3baa7504c52da 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -18,7 +18,12 @@ 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 @@ def __init__( ) 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 @@ async def _async_update_data(self) -> 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/humidifier.py b/homeassistant/components/comelit/humidifier.py index 4a7361022ce7ee..b21682b6958924 100644 --- a/homeassistant/components/comelit/humidifier.py +++ b/homeassistant/components/comelit/humidifier.py @@ -9,7 +9,6 @@ from aiocomelit.const import CLIMATE from homeassistant.components.humidifier import ( - DOMAIN as HUMIDIFIER_DOMAIN, MODE_AUTO, MODE_NORMAL, HumidifierAction, @@ -68,7 +67,7 @@ async def async_setup_entry( entities: list[ComelitHumidifierEntity] = [] for device in coordinator.data[CLIMATE].values(): - values = load_api_data(device, HUMIDIFIER_DOMAIN) + values = load_api_data(device, "humidifier") if values[0] == 0 and values[4] == 0: # No humidity data, device is only a climate @@ -142,7 +141,7 @@ def __init__( def _update_attributes(self) -> None: """Update class attributes.""" device = self.coordinator.data[CLIMATE][self._device.index] - values = load_api_data(device, HUMIDIFIER_DOMAIN) + values = load_api_data(device, "humidifier") _active = values[1] _mode = values[2] # Values from API: "O", "L", "U" diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index f776cf6b3ee76a..ee4ac563a48b7d 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["aiocomelit"], "quality_scale": "platinum", - "requirements": ["aiocomelit==2.0.2"] + "requirements": ["aiocomelit==2.0.3"] } diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 6d8e450c9e8c8c..accc0ccdcdb316 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,12 +121,12 @@ "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" }, - "invalid_clima_data": { - "message": "Invalid 'clima' data" - }, "update_failed": { "message": "Failed to update data: {error}" } diff --git a/homeassistant/components/comelit/utils.py b/homeassistant/components/comelit/utils.py index 459b73a3ff950e..30f5d691f41097 100644 --- a/homeassistant/components/comelit/utils.py +++ b/homeassistant/components/comelit/utils.py @@ -2,13 +2,17 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -from typing import TYPE_CHECKING, Any, Concatenate +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.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -30,17 +34,19 @@ async def async_client_session(hass: HomeAssistant) -> ClientSession: ) -def load_api_data(device: ComelitSerialBridgeObject, domain: str) -> list[Any]: +def load_api_data( + device: ComelitSerialBridgeObject, + domain: Literal["climate", "humidifier"], +) -> list[Any]: """Load data from the API.""" - # This function is called when the data is loaded from the API - if not isinstance(device.val, list): - raise HomeAssistantError( - translation_domain=domain, translation_key="invalid_clima_data" - ) + # This function is called when the data is loaded from the API. + # For climate and humidifier device.val is always a list. + if TYPE_CHECKING: + assert isinstance(device.val, list) # CLIMATE has a 2 item tuple: # - first for Clima # - second for Humidifier - return device.val[0] if domain == CLIMATE_DOMAIN else device.val[1] + return device.val[0] if domain == "climate" else device.val[1] async def cleanup_stale_entity( @@ -109,6 +115,12 @@ async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: 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/config/auth.py b/homeassistant/components/config/auth.py index 1b3fa71d7ea848..d1bc96397363cb 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -10,32 +10,19 @@ 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 +36,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/entity_registry.py b/homeassistant/components/config/entity_registry.py index ce9f315ff78037..86c5a8dd3edbe9 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -210,7 +210,7 @@ def websocket_update_entity( ) return - changes = {} + changes: dict[str, Any] = {} for key in ( "area_id", diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index d1ddcb6cd4b8e6..063fce654fafab 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -14,6 +14,8 @@ 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 +26,8 @@ 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 +56,6 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -@bind_hass @async_callback def async_request_config( hass: HomeAssistant, @@ -93,7 +94,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 +104,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 +111,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 +118,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 +125,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 +152,12 @@ def __init__(self, hass: HomeAssistant) -> None: 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/config_flow.py b/homeassistant/components/control4/config_flow.py index 39360459cbd825..942354aca559ae 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -169,6 +169,8 @@ async def async_step_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/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index b386121543c933..69aab381bf2775 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -23,7 +23,6 @@ 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 +126,6 @@ @callback -@bind_hass def async_set_agent( hass: HomeAssistant, config_entry: ConfigEntry, @@ -138,7 +136,6 @@ def async_set_agent( @callback -@bind_hass def async_unset_agent( hass: HomeAssistant, config_entry: ConfigEntry, diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 7317aea82852ef..40629a05a16ee7 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/counter/conditions.yaml b/homeassistant/components/counter/conditions.yaml index 6a00235d287108..50081533ec34d2 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/strings.json b/homeassistant/components/counter/strings.json index 4e728b0bc44e9d..1f08ba33ae9728 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -1,6 +1,8 @@ { "common": { - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_value": { @@ -9,6 +11,9 @@ "behavior": { "name": "Condition passes if" }, + "for": { + "name": "[%key:component::counter::common::condition_for_name%]" + }, "threshold": { "name": "Threshold type" } @@ -42,21 +47,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "decrement": { "description": "Decrements a counter by its step size.", @@ -96,6 +86,9 @@ "fields": { "behavior": { "name": "[%key:component::counter::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::counter::common::trigger_for_name%]" } }, "name": "Counter reached maximum" @@ -105,6 +98,9 @@ "fields": { "behavior": { "name": "[%key:component::counter::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::counter::common::trigger_for_name%]" } }, "name": "Counter reached minimum" @@ -114,6 +110,9 @@ "fields": { "behavior": { "name": "[%key:component::counter::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::counter::common::trigger_for_name%]" } }, "name": "Counter reset" diff --git a/homeassistant/components/counter/triggers.yaml b/homeassistant/components/counter/triggers.yaml index b424d1769d71cc..0dcfbb81b19ce3 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 7dc9bd26d0372f..6caf1d0a822812 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -29,7 +29,6 @@ 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 +86,6 @@ ] -@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 075f3a926bc547..6db398fd069ce0 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/strings.json b/homeassistant/components/cover/strings.json index 3be0ed28d79f54..502168bcc79a6e 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "awning_is_closed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Awning is closed" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Awning is open" @@ -27,6 +35,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Blind is closed" @@ -36,6 +47,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Blind is open" @@ -45,6 +59,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Curtain is closed" @@ -54,6 +71,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Curtain is open" @@ -63,6 +83,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shade is closed" @@ -72,6 +95,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shade is open" @@ -81,6 +107,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shutter is closed" @@ -90,6 +119,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::condition_for_name%]" } }, "name": "Shutter is open" @@ -178,21 +210,6 @@ "name": "Window" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "close_cover": { "description": "Closes a cover.", @@ -254,6 +271,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Awning closed" @@ -263,6 +283,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Awning opened" @@ -272,6 +295,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Blind closed" @@ -281,6 +307,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Blind opened" @@ -290,6 +319,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Curtain closed" @@ -299,6 +331,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Curtain opened" @@ -308,6 +343,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Shade closed" @@ -317,6 +355,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Shade opened" @@ -326,6 +367,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Shutter closed" @@ -335,6 +379,9 @@ "fields": { "behavior": { "name": "[%key:component::cover::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::cover::common::trigger_for_name%]" } }, "name": "Shutter opened" diff --git a/homeassistant/components/cover/triggers.yaml b/homeassistant/components/cover/triggers.yaml index 4b9d0a054dc9c8..6d8c6b1cff35f2 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/crownstone/config_flow.py b/homeassistant/components/crownstone/config_flow.py index 5f5af4f51a4647..64baf7f49a1b1b 100644 --- a/homeassistant/components/crownstone/config_flow.py +++ b/homeassistant/components/crownstone/config_flow.py @@ -10,8 +10,6 @@ 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 +59,11 @@ async def async_step_usb_config( 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 +82,8 @@ async def async_step_usb_config( 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/helpers.py b/homeassistant/components/crownstone/helpers.py index 4da8bc8dbe75b3..829cf7354dba03 100644 --- a/homeassistant/components/crownstone/helpers.py +++ b/homeassistant/components/crownstone/helpers.py @@ -5,15 +5,14 @@ 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 +30,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/manifest.json b/homeassistant/components/crownstone/manifest.json index 6168d483ab535d..7eb3dbd31ba7d8 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/deconz/services.py b/homeassistant/components/deconz/services.py index b3c900c07c4b13..95f81f9a8a75fa 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -11,6 +11,7 @@ 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 @@ async def async_call_deconz_service(service_call: ServiceCall) -> 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/decora_wifi/__init__.py b/homeassistant/components/decora_wifi/__init__.py index e6f9a1e2b0d506..e30efc67c885ad 100644 --- a/homeassistant/components/decora_wifi/__init__.py +++ b/homeassistant/components/decora_wifi/__init__.py @@ -19,7 +19,7 @@ Platform, ) from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady PLATFORMS = [Platform.LIGHT] @@ -40,7 +40,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/demo/image_processing.py b/homeassistant/components/demo/image_processing.py index d109f55f5a2b59..84d60f1d024ccd 100644 --- a/homeassistant/components/demo/image_processing.py +++ b/homeassistant/components/demo/image_processing.py @@ -45,7 +45,7 @@ def confidence(self) -> int: """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 +58,4 @@ def process_image(self, image: bytes) -> None: 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/remote.py b/homeassistant/components/demo/remote.py index ffd6fd6e6096b1..b8354edaaea942 100644 --- a/homeassistant/components/demo/remote.py +++ b/homeassistant/components/demo/remote.py @@ -44,18 +44,18 @@ def extra_state_attributes(self) -> dict[str, Any] | None: return {"last_command_sent": self._last_command_sent} return None - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the remote on.""" self._attr_is_on = True - self.schedule_update_ha_state() + self.async_write_ha_state() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the remote off.""" self._attr_is_on = False - self.schedule_update_ha_state() + self.async_write_ha_state() - def send_command(self, command: Iterable[str], **kwargs: Any) -> None: + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: """Send a command to a device.""" for com in command: self._last_command_sent = com - self.schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index dd288f285af0c1..214f64e8a49409 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -61,12 +61,12 @@ def __init__( name=device_name, ) - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" self._attr_is_on = True - self.schedule_update_ha_state() + self.async_write_ha_state() - def turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" self._attr_is_on = False - self.schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/denon/__init__.py b/homeassistant/components/denon/__init__.py index ab8cd1b896e84f..524d9becfc743c 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_rs232/__init__.py b/homeassistant/components/denon_rs232/__init__.py new file mode 100644 index 00000000000000..2dec481748d56c --- /dev/null +++ b/homeassistant/components/denon_rs232/__init__.py @@ -0,0 +1,57 @@ +"""The Denon RS232 integration.""" + +from __future__ import annotations + +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 00000000000000..0f301a94b224cf --- /dev/null +++ b/homeassistant/components/denon_rs232/config_flow.py @@ -0,0 +1,119 @@ +"""Config flow for the Denon RS232 integration.""" + +from __future__ import annotations + +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 00000000000000..a408bd33509f56 --- /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 00000000000000..63d177120c64c1 --- /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 00000000000000..ee6df81353bb25 --- /dev/null +++ b/homeassistant/components/denon_rs232/media_player.py @@ -0,0 +1,235 @@ +"""Media player platform for the Denon RS232 integration.""" + +from __future__ import annotations + +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 00000000000000..e7b4993cd67301 --- /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 00000000000000..2ed91a0fb290a0 --- /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 cd68308e124f5c..09b084ec07cab4 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/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 8515b54295a1dc..974dcf6c9b4771 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -187,6 +187,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/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index dde1ee7bfe0814..b77efaeb65873c 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -11,7 +11,6 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.condition import ( Condition, - ConditionChecker, ConditionCheckerType, ConditionConfig, ) @@ -54,6 +53,7 @@ class DeviceCondition(Condition): """Device condition.""" _config: ConfigType + _platform_checker: ConditionCheckerType @classmethod async def async_validate_complete_config( @@ -87,20 +87,19 @@ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: 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_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index 313373e3181b88..bc87a6e073e65c 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -5,7 +5,6 @@ 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 +20,7 @@ ATTR_DEV_ID, ATTR_GPS, ATTR_HOST_NAME, + ATTR_IN_ZONES, ATTR_IP, ATTR_LOCATION_NAME, ATTR_MAC, @@ -51,7 +51,6 @@ ) -@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 1593f93f21a0a1..00000000000000 --- 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 0f92b51c20d9e6..00000000000000 --- 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 b82cf0352a72ee..15beb879ae4fb8 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from typing import final +from typing import Any, final from propcache.api import cached_property @@ -18,7 +18,7 @@ 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 +33,7 @@ from .const import ( ATTR_HOST_NAME, + ATTR_IN_ZONES, ATTR_IP, ATTR_MAC, ATTR_SOURCE_TYPE, @@ -223,6 +224,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 +260,18 @@ def longitude(self) -> float | None: """Return longitude value of the device.""" return self._attr_longitude + @callback + def _async_write_ha_state(self) -> None: + """Calculate active zones.""" + if self.available and self.latitude is not None and self.longitude is not None: + self.__active_zone, self.__in_zones = zone.async_in_zones( + self.hass, self.latitude, self.longitude, self.location_accuracy + ) + else: + self.__active_zone = None + self.__in_zones = None + super()._async_write_ha_state() + @property def state(self) -> str | None: """Return the state of the device.""" @@ -263,9 +279,7 @@ def state(self) -> str | None: 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 +292,13 @@ def state(self) -> str | None: @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 c9e4d4e910a58b..87b8f7d2cbf57d 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -43,6 +43,7 @@ class SourceType(StrEnum): 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/icons.json b/homeassistant/components/device_tracker/icons.json index 4ba56719cb6f3f..4e5b82576cf8b4 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 5923aa2ed45fb5..b91f85955e240c 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -6,6 +6,7 @@ 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 +83,8 @@ SourceType, ) +_LOGGER = logging.getLogger(__name__) + SERVICE_SEE: Final = "see" SOURCE_TYPES = [cls.value for cls in SourceType] @@ -128,6 +131,8 @@ 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 +248,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 +343,18 @@ async def async_setup_legacy( 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 ff71fb30c65c83..c37fa04e76024b 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -1,28 +1,4 @@ { - "common": { - "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" - }, - "conditions": { - "is_home": { - "description": "Tests if one or more device trackers are home.", - "fields": { - "behavior": { - "name": "[%key:component::device_tracker::common::condition_behavior_name%]" - } - }, - "name": "Device tracker is home" - }, - "is_not_home": { - "description": "Tests if one or more device trackers are not home.", - "fields": { - "behavior": { - "name": "[%key:component::device_tracker::common::condition_behavior_name%]" - } - }, - "name": "Device tracker is not home" - } - }, "device_automation": { "condition_type": { "is_home": "{entity_name} is home", @@ -68,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.", @@ -119,25 +80,5 @@ "name": "See device tracker" } }, - "title": "Device tracker", - "triggers": { - "entered_home": { - "description": "Triggers when one or more device trackers enter home.", - "fields": { - "behavior": { - "name": "[%key:component::device_tracker::common::trigger_behavior_name%]" - } - }, - "name": "Entered home" - }, - "left_home": { - "description": "Triggers when one or more device trackers leave home.", - "fields": { - "behavior": { - "name": "[%key:component::device_tracker::common::trigger_behavior_name%]" - } - }, - "name": "Left home" - } - } + "title": "Device tracker" } diff --git a/homeassistant/components/device_tracker/trigger.py b/homeassistant/components/device_tracker/trigger.py deleted file mode 100644 index 7f1d2bd068e669..00000000000000 --- 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 e75f072ba8cc4f..00000000000000 --- 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/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index e601728d851956..9f711ad9c2978d 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -2,6 +2,8 @@ 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 +190,8 @@ def unique_id(self) -> str: 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_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 79d00ee50be3e5..ecc5b071298898 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -7,6 +7,7 @@ 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 +18,7 @@ ) 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 +125,25 @@ async def disconnect(event: Event) -> None: 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/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index 941eec4215d1da..17cb2a59231425 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -117,7 +117,7 @@ class DevoloSensorEntityDescription[ 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 3de47e9a3fc5ac..650a638829ce58 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/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index a19f3c888e5dac..26a6441e4ba868 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -245,6 +245,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/dlna_dmr/data.py b/homeassistant/components/dlna_dmr/data.py index 7af396f7c606d0..349b295d480979 100644 --- a/homeassistant/components/dlna_dmr/data.py +++ b/homeassistant/components/dlna_dmr/data.py @@ -1,4 +1,5 @@ """Data used by this integration.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 1c43d76ea0840a..e49679f5d5517b 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -353,10 +353,10 @@ async def async_config_update_listener( # Device was de/re-connected, state might have changed self.async_write_ha_state() - def async_write_ha_state(self) -> None: + def _async_write_ha_state(self) -> None: """Write the state.""" self._attr_supported_features = self._supported_features() - super().async_write_ha_state() + super()._async_write_ha_state() async def _device_connect(self, location: str) -> None: """Connect to the device now that it's available.""" diff --git a/homeassistant/components/dlna_dms/dms.py b/homeassistant/components/dlna_dms/dms.py index 8da971b7b49b6d..5d480b2eda666b 100644 --- a/homeassistant/components/dlna_dms/dms.py +++ b/homeassistant/components/dlna_dms/dms.py @@ -1,4 +1,5 @@ """Wrapper for media_source around async_upnp_client's DmsDevice .""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 3487ce83c7bf4b..a2de27131c3782 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -1,4 +1,4 @@ -"""The dnsip component.""" +"""The DNS IP integration.""" from __future__ import annotations @@ -17,7 +17,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 +30,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 +44,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 0ea2a9d092b92e..1e83c7743f24d5 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -93,7 +93,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 +133,7 @@ async def async_step_user( ): 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/door/conditions.yaml b/homeassistant/components/door/conditions.yaml index ed1c3d79ec5da4..ca1bff0bfdd479 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 c6e5961ceff7ac..40ed892e658549 100644 --- a/homeassistant/components/door/strings.json +++ b/homeassistant/components/door/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_closed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::door::common::condition_for_name%]" } }, "name": "Door is closed" @@ -18,26 +23,14 @@ "fields": { "behavior": { "name": "[%key:component::door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::door::common::condition_for_name%]" } }, "name": "Door is open" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Door", "triggers": { "closed": { @@ -45,6 +38,9 @@ "fields": { "behavior": { "name": "[%key:component::door::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::door::common::trigger_for_name%]" } }, "name": "Door closed" @@ -54,6 +50,9 @@ "fields": { "behavior": { "name": "[%key:component::door::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::door::common::trigger_for_name%]" } }, "name": "Door opened" diff --git a/homeassistant/components/door/triggers.yaml b/homeassistant/components/door/triggers.yaml index 770a79f22215ad..8f82a03c58e0fd 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 00000000000000..50f50bbe2adfb4 --- /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 00000000000000..aecd411fc9b8ce --- /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 00000000000000..9fd730c10793f5 --- /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 00000000000000..8ca74a7a2c4505 --- /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 00000000000000..04f011c80726d6 --- /dev/null +++ b/homeassistant/components/doorbell/trigger.py @@ -0,0 +1,50 @@ +"""Provides triggers for doorbells.""" + +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + DOMAIN as EVENT_DOMAIN, + DoorbellEventType, + EventDeviceClass, +) +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.automation import DomainSpec +from homeassistant.helpers.trigger import ( + ENTITY_STATE_TRIGGER_SCHEMA, + EntityTriggerBase, + Trigger, +) + + +class DoorbellRangTrigger(EntityTriggerBase): + """Trigger for doorbell event entity when a ring event is received.""" + + _domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)} + _schema = ENTITY_STATE_TRIGGER_SCHEMA + + def is_valid_state(self, state: State) -> bool: + """Check if the entity is available and the event type is ring.""" + return ( + state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + and state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING + ) + + 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 + + +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 00000000000000..86e2e38a8d5193 --- /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/dropbox/__init__.py b/homeassistant/components/dropbox/__init__.py new file mode 100644 index 00000000000000..4be8074a5cd188 --- /dev/null +++ b/homeassistant/components/dropbox/__init__.py @@ -0,0 +1,64 @@ +"""The Dropbox integration.""" + +from __future__ import annotations + +from python_dropbox_api import ( + DropboxAPIClient, + DropboxAuthException, + DropboxUnknownException, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, + OAuth2Session, + async_get_config_entry_implementation, +) + +from .auth import DropboxConfigEntryAuth +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +type DropboxConfigEntry = ConfigEntry[DropboxAPIClient] + + +async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool: + """Set up Dropbox from a config entry.""" + try: + oauth2_implementation = await async_get_config_entry_implementation(hass, entry) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err + oauth2_session = OAuth2Session(hass, entry, oauth2_implementation) + + auth = DropboxConfigEntryAuth( + aiohttp_client.async_get_clientsession(hass), oauth2_session + ) + + client = DropboxAPIClient(auth) + + try: + await client.get_account_info() + except DropboxAuthException as err: + raise ConfigEntryAuthFailed from err + except (DropboxUnknownException, TimeoutError) as err: + raise ConfigEntryNotReady from err + + entry.runtime_data = client + + def async_notify_backup_listeners() -> None: + for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []): + listener() + + entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool: + """Unload a config entry.""" + return True diff --git a/homeassistant/components/dropbox/application_credentials.py b/homeassistant/components/dropbox/application_credentials.py new file mode 100644 index 00000000000000..3babe856a28aca --- /dev/null +++ b/homeassistant/components/dropbox/application_credentials.py @@ -0,0 +1,38 @@ +"""Application credentials platform for the Dropbox integration.""" + +from homeassistant.components.application_credentials import ClientCredential +from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + AbstractOAuth2Implementation, + LocalOAuth2ImplementationWithPkce, +) + +from .const import OAUTH2_AUTHORIZE, OAUTH2_SCOPES, OAUTH2_TOKEN + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> AbstractOAuth2Implementation: + """Return custom auth implementation.""" + return DropboxOAuth2Implementation( + hass, + auth_domain, + credential.client_id, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + credential.client_secret, + ) + + +class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce): + """Custom Dropbox OAuth2 implementation to add the necessary authorize url parameters.""" + + @property + def extra_authorize_data(self) -> dict: + """Extra data that needs to be appended to the authorize url.""" + data: dict = { + "token_access_type": "offline", + "scope": " ".join(OAUTH2_SCOPES), + } + data.update(super().extra_authorize_data) + return data diff --git a/homeassistant/components/dropbox/auth.py b/homeassistant/components/dropbox/auth.py new file mode 100644 index 00000000000000..da6d72f6748f23 --- /dev/null +++ b/homeassistant/components/dropbox/auth.py @@ -0,0 +1,44 @@ +"""Authentication for Dropbox.""" + +from typing import cast + +from aiohttp import ClientSession +from python_dropbox_api import Auth + +from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session + + +class DropboxConfigEntryAuth(Auth): + """Provide Dropbox authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + websession: ClientSession, + oauth_session: OAuth2Session, + ) -> None: + """Initialize DropboxConfigEntryAuth.""" + super().__init__(websession) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + await self._oauth_session.async_ensure_token_valid() + + return cast(str, self._oauth_session.token["access_token"]) + + +class DropboxConfigFlowAuth(Auth): + """Provide authentication tied to a fixed token for the config flow.""" + + def __init__( + self, + websession: ClientSession, + token: str, + ) -> None: + """Initialize DropboxConfigFlowAuth.""" + super().__init__(websession) + self._token = token + + async def async_get_access_token(self) -> str: + """Return the fixed access token.""" + return self._token diff --git a/homeassistant/components/dropbox/backup.py b/homeassistant/components/dropbox/backup.py new file mode 100644 index 00000000000000..bc7af3d5cbc859 --- /dev/null +++ b/homeassistant/components/dropbox/backup.py @@ -0,0 +1,230 @@ +"""Backup platform for the Dropbox integration.""" + +from collections.abc import AsyncIterator, Callable, Coroutine +from functools import wraps +import json +import logging +from typing import Any, Concatenate + +from python_dropbox_api import ( + DropboxAPIClient, + DropboxAuthException, + DropboxFileOrFolderNotFoundException, + DropboxUnknownException, +) + +from homeassistant.components.backup import ( + AgentBackup, + BackupAgent, + BackupAgentError, + BackupNotFound, + suggested_filename, +) +from homeassistant.core import HomeAssistant, callback + +from . import DropboxConfigEntry +from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]: + """Return the suggested filenames for the backup and metadata.""" + base_name = suggested_filename(backup).rsplit(".", 1)[0] + return f"{base_name}.tar", f"{base_name}.metadata.json" + + +async def _async_string_iterator(content: str) -> AsyncIterator[bytes]: + """Yield a string as a single bytes chunk.""" + yield content.encode() + + +def handle_backup_errors[_R, **P]( + func: Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]], +) -> Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]]: + """Handle backup errors.""" + + @wraps(func) + async def wrapper( + self: DropboxBackupAgent, *args: P.args, **kwargs: P.kwargs + ) -> _R: + try: + return await func(self, *args, **kwargs) + except DropboxFileOrFolderNotFoundException as err: + raise BackupNotFound( + f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}" + ) from err + except DropboxAuthException as err: + self._entry.async_start_reauth(self._hass) + raise BackupAgentError("Authentication error") from err + except DropboxUnknownException as err: + _LOGGER.error( + "Error during %s: %s", + func.__name__, + err, + ) + _LOGGER.debug("Full error: %s", err, exc_info=True) + raise BackupAgentError( + f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}" + ) from err + + return wrapper + + +async def async_get_backup_agents( + hass: HomeAssistant, + **kwargs: Any, +) -> list[BackupAgent]: + """Return a list of backup agents.""" + entries = hass.config_entries.async_loaded_entries(DOMAIN) + return [DropboxBackupAgent(hass, entry) for entry in entries] + + +@callback +def async_register_backup_agents_listener( + hass: HomeAssistant, + *, + listener: Callable[[], None], + **kwargs: Any, +) -> Callable[[], None]: + """Register a listener to be called when agents are added or removed. + + :return: A function to unregister the listener. + """ + hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener) + + @callback + def remove_listener() -> None: + """Remove the listener.""" + hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener) + if not hass.data[DATA_BACKUP_AGENT_LISTENERS]: + del hass.data[DATA_BACKUP_AGENT_LISTENERS] + + return remove_listener + + +class DropboxBackupAgent(BackupAgent): + """Backup agent for the Dropbox integration.""" + + domain = DOMAIN + + def __init__(self, hass: HomeAssistant, entry: DropboxConfigEntry) -> None: + """Initialize the backup agent.""" + super().__init__() + self._hass = hass + self._entry = entry + self.name = entry.title + assert entry.unique_id + self.unique_id = entry.unique_id + self._api: DropboxAPIClient = entry.runtime_data + + async def _async_get_backups(self) -> list[tuple[AgentBackup, str]]: + """Get backups and their corresponding file names.""" + files = await self._api.list_folder("") + + tar_files = {f.name for f in files if f.name.endswith(".tar")} + metadata_files = [f for f in files if f.name.endswith(".metadata.json")] + + backups: list[tuple[AgentBackup, str]] = [] + for metadata_file in metadata_files: + tar_name = metadata_file.name.removesuffix(".metadata.json") + ".tar" + if tar_name not in tar_files: + _LOGGER.warning( + "Found metadata file '%s' without matching backup file", + metadata_file.name, + ) + continue + + metadata_stream = self._api.download_file(f"/{metadata_file.name}") + raw = b"".join([chunk async for chunk in metadata_stream]) + try: + data = json.loads(raw) + backup = AgentBackup.from_dict(data) + except (json.JSONDecodeError, ValueError, TypeError, KeyError) as err: + _LOGGER.warning( + "Skipping invalid metadata file '%s': %s", + metadata_file.name, + err, + ) + continue + backups.append((backup, tar_name)) + + return backups + + @handle_backup_errors + async def async_upload_backup( + self, + *, + open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]], + backup: AgentBackup, + **kwargs: Any, + ) -> None: + """Upload a backup.""" + backup_filename, metadata_filename = _suggested_filenames(backup) + backup_path = f"/{backup_filename}" + metadata_path = f"/{metadata_filename}" + + file_stream = await open_stream() + await self._api.upload_file(backup_path, file_stream) + + metadata_stream = _async_string_iterator(json.dumps(backup.as_dict())) + + try: + await self._api.upload_file(metadata_path, metadata_stream) + except ( + DropboxAuthException, + DropboxUnknownException, + ): + await self._api.delete_file(backup_path) + raise + + @handle_backup_errors + async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]: + """List backups.""" + return [backup for backup, _ in await self._async_get_backups()] + + @handle_backup_errors + async def async_download_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AsyncIterator[bytes]: + """Download a backup file.""" + backups = await self._async_get_backups() + for backup, filename in backups: + if backup.backup_id == backup_id: + return self._api.download_file(f"/{filename}") + + raise BackupNotFound(f"Backup {backup_id} not found") + + @handle_backup_errors + async def async_get_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> AgentBackup: + """Return a backup.""" + backups = await self._async_get_backups() + + for backup, _ in backups: + if backup.backup_id == backup_id: + return backup + + raise BackupNotFound(f"Backup {backup_id} not found") + + @handle_backup_errors + async def async_delete_backup( + self, + backup_id: str, + **kwargs: Any, + ) -> None: + """Delete a backup file.""" + backups = await self._async_get_backups() + for backup, tar_filename in backups: + if backup.backup_id == backup_id: + metadata_filename = tar_filename.removesuffix(".tar") + ".metadata.json" + await self._api.delete_file(f"/{tar_filename}") + await self._api.delete_file(f"/{metadata_filename}") + return + + raise BackupNotFound(f"Backup {backup_id} not found") diff --git a/homeassistant/components/dropbox/config_flow.py b/homeassistant/components/dropbox/config_flow.py new file mode 100644 index 00000000000000..045f858bd59b89 --- /dev/null +++ b/homeassistant/components/dropbox/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for Dropbox.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from python_dropbox_api import DropboxAPIClient + +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler + +from .auth import DropboxConfigFlowAuth +from .const import DOMAIN + + +class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): + """Config flow to handle Dropbox OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Create an entry for the flow, or update existing entry.""" + access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN] + + auth = DropboxConfigFlowAuth(async_get_clientsession(self.hass), access_token) + + client = DropboxAPIClient(auth) + account_info = await client.get_account_info() + + await self.async_set_unique_id(account_info.account_id) + if self.source == SOURCE_REAUTH: + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=data + ) + + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=account_info.email, data=data) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() diff --git a/homeassistant/components/dropbox/const.py b/homeassistant/components/dropbox/const.py new file mode 100644 index 00000000000000..042f5b5c7bfddf --- /dev/null +++ b/homeassistant/components/dropbox/const.py @@ -0,0 +1,19 @@ +"""Constants for the Dropbox integration.""" + +from collections.abc import Callable + +from homeassistant.util.hass_dict import HassKey + +DOMAIN = "dropbox" + +OAUTH2_AUTHORIZE = "https://www.dropbox.com/oauth2/authorize" +OAUTH2_TOKEN = "https://api.dropboxapi.com/oauth2/token" +OAUTH2_SCOPES = [ + "account_info.read", + "files.content.read", + "files.content.write", +] + +DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey( + f"{DOMAIN}.backup_agent_listeners" +) diff --git a/homeassistant/components/dropbox/manifest.json b/homeassistant/components/dropbox/manifest.json new file mode 100644 index 00000000000000..01254682b79285 --- /dev/null +++ b/homeassistant/components/dropbox/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "dropbox", + "name": "Dropbox", + "after_dependencies": ["backup"], + "codeowners": ["@bdr99"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/dropbox", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["python-dropbox-api==0.1.3"] +} diff --git a/homeassistant/components/dropbox/quality_scale.yaml b/homeassistant/components/dropbox/quality_scale.yaml new file mode 100644 index 00000000000000..3f46b70b7a5e1f --- /dev/null +++ b/homeassistant/components/dropbox/quality_scale.yaml @@ -0,0 +1,112 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register any actions. + appropriate-polling: + status: exempt + comment: Integration does not poll. + brands: done + common-modules: + status: exempt + comment: Integration does not have any entities or coordinators. + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register any actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not have any entities. + entity-unique-id: + status: exempt + comment: Integration does not have any entities. + has-entity-name: + status: exempt + comment: Integration does not have any entities. + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register any actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration does not have any configuration parameters. + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: Integration does not have any entities. + integration-owner: done + log-when-unavailable: todo + parallel-updates: + status: exempt + comment: Integration does not make any entity updates. + reauthentication-flow: done + test-coverage: done + + # Gold + devices: + status: exempt + comment: Integration does not have any entities. + diagnostics: + status: exempt + comment: Integration does not have any data to diagnose. + discovery-update-info: + status: exempt + comment: Integration is a service. + discovery: + status: exempt + comment: Integration is a service. + docs-data-update: + status: exempt + comment: Integration does not update any data. + docs-examples: + status: exempt + comment: Integration only provides backup functionality. + docs-known-limitations: todo + docs-supported-devices: + status: exempt + comment: Integration does not support any devices. + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: + status: exempt + comment: Integration does not use any devices. + entity-category: + status: exempt + comment: Integration does not have any entities. + entity-device-class: + status: exempt + comment: Integration does not have any entities. + entity-disabled-by-default: + status: exempt + comment: Integration does not have any entities. + entity-translations: + status: exempt + comment: Integration does not have any entities. + exception-translations: todo + icon-translations: + status: exempt + comment: Integration does not have any entities. + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: Integration does not have any repairs. + stale-devices: + status: exempt + comment: Integration does not have any devices. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/dropbox/strings.json b/homeassistant/components/dropbox/strings.json new file mode 100644 index 00000000000000..4904f997e314e7 --- /dev/null +++ b/homeassistant/components/dropbox/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "wrong_account": "Wrong account: Please authenticate with the correct account." + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + }, + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "reauth_confirm": { + "description": "The Dropbox integration needs to re-authenticate your account.", + "title": "[%key:common::config_flow::title::reauth%]" + } + } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } + } +} diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py index 577def8b3ecd14..fc34f0dd6473d5 100644 --- a/homeassistant/components/dsmr/config_flow.py +++ b/homeassistant/components/dsmr/config_flow.py @@ -4,7 +4,6 @@ import asyncio from functools import partial -import os from typing import Any from dsmr_parser import obis_references as obis_ref @@ -15,9 +14,9 @@ ) 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 +228,7 @@ async def async_step_setup_serial( 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 +239,10 @@ async def async_step_setup_serial( 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 +333,6 @@ async def async_step_init( ) -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/manifest.json b/homeassistant/components/dsmr/manifest.json index 32366c5578400c..4dd031f1ac44c4 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 0c4595e8f7f204..0d050b4c95b32f 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -87,6 +87,7 @@ class MbusDeviceType(IntEnum): GAS = 3 HEAT = 4 WATER = 7 + HEAT_COOL = 12 SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( @@ -571,6 +572,16 @@ class MbusDeviceType(IntEnum): 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/duco/__init__.py b/homeassistant/components/duco/__init__.py new file mode 100644 index 00000000000000..dbec4d061bb3fa --- /dev/null +++ b/homeassistant/components/duco/__init__.py @@ -0,0 +1,36 @@ +"""The Duco integration.""" + +from __future__ import annotations + +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 00000000000000..d16371981c5f16 --- /dev/null +++ b/homeassistant/components/duco/config_flow.py @@ -0,0 +1,171 @@ +"""Config flow for the Duco integration.""" + +from __future__ import annotations + +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 00000000000000..c3dde6ce0466e3 --- /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 00000000000000..531d843f39e2a9 --- /dev/null +++ b/homeassistant/components/duco/coordinator.py @@ -0,0 +1,102 @@ +"""Data update coordinator for the Duco integration.""" + +from __future__ import annotations + +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 00000000000000..e21079984ed9c7 --- /dev/null +++ b/homeassistant/components/duco/diagnostics.py @@ -0,0 +1,61 @@ +"""Diagnostics support for Duco.""" + +from __future__ import annotations + +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 + +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) + board.pop("time") + + try: + 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 + + return async_redact_data( + { + "entry_data": entry.data, + "board_info": board, + "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 00000000000000..5300e1e072d0c8 --- /dev/null +++ b/homeassistant/components/duco/entity.py @@ -0,0 +1,52 @@ +"""Base entity for the Duco integration.""" + +from __future__ import annotations + +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 00000000000000..8b590ac28f0b9b --- /dev/null +++ b/homeassistant/components/duco/fan.py @@ -0,0 +1,138 @@ +"""Fan platform for the Duco integration.""" + +from __future__ import annotations + +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 00000000000000..67947d2f87398f --- /dev/null +++ b/homeassistant/components/duco/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "iaq_co2": { + "default": "mdi:molecule-co2" + }, + "iaq_rh": { + "default": "mdi:water-percent" + }, + "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 00000000000000..aa7749962888d8 --- /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.3.10"], + "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 00000000000000..598e5529854105 --- /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 00000000000000..9fca41d85193f2 --- /dev/null +++ b/homeassistant/components/duco/sensor.py @@ -0,0 +1,245 @@ +"""Sensor platform for the Duco integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +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 .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], 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="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) -> 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 00000000000000..da70af200e10ef --- /dev/null +++ b/homeassistant/components/duco/strings.json @@ -0,0 +1,100 @@ +{ + "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" + }, + "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/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json index cf92c537bc8780..a4f69618775567 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/eafm/__init__.py b/homeassistant/components/eafm/__init__.py index ff1d622139af28..019e5adc137349 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 00000000000000..b63a5fec8b0b17 --- /dev/null +++ b/homeassistant/components/earn_e_p1/__init__.py @@ -0,0 +1,61 @@ +"""The EARN-E P1 Meter integration.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern + +from __future__ import annotations + +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 00000000000000..dd764213b159e4 --- /dev/null +++ b/homeassistant/components/earn_e_p1/config_flow.py @@ -0,0 +1,154 @@ +"""Config flow for the EARN-E P1 Meter integration.""" + +from __future__ import annotations + +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 00000000000000..7394e3dc817941 --- /dev/null +++ b/homeassistant/components/earn_e_p1/const.py @@ -0,0 +1,6 @@ +"""Constants for the EARN-E P1 Meter integration.""" + +from __future__ import annotations + +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 00000000000000..3a270c33e29bd2 --- /dev/null +++ b/homeassistant/components/earn_e_p1/coordinator.py @@ -0,0 +1,71 @@ +"""DataUpdateCoordinator for the EARN-E P1 Meter integration.""" + +from __future__ import annotations + +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 00000000000000..1677c25286dfb6 --- /dev/null +++ b/homeassistant/components/earn_e_p1/entity.py @@ -0,0 +1,27 @@ +"""Base entity for the EARN-E P1 Meter integration.""" + +from __future__ import annotations + +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 00000000000000..39f9064f14e53f --- /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 00000000000000..cb5f4ea74939db --- /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 00000000000000..cd6e8a044c032b --- /dev/null +++ b/homeassistant/components/earn_e_p1/sensor.py @@ -0,0 +1,161 @@ +"""Sensor platform for the EARN-E P1 Meter integration.""" + +from __future__ import annotations + +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 00000000000000..903cf82b88df39 --- /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/diagnostics.py b/homeassistant/components/easyenergy/diagnostics.py index 64f30ba61fdacb..55a3614e495ca5 100644 --- a/homeassistant/components/easyenergy/diagnostics.py +++ b/homeassistant/components/easyenergy/diagnostics.py @@ -6,6 +6,7 @@ from typing import Any from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from .coordinator import EasyEnergyConfigEntry, EasyEnergyData @@ -23,9 +24,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 +39,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 c987e75e7180ff..7c0c00f7607528 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 35fab870af381f..c0638344cb656b 100644 --- a/homeassistant/components/easyenergy/sensor.py +++ b/homeassistant/components/easyenergy/sensor.py @@ -24,6 +24,7 @@ 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 +64,7 @@ class EasyEnergySensorEntityDescription(SensorEntityDescription): 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 +72,7 @@ class EasyEnergySensorEntityDescription(SensorEntityDescription): 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 +80,42 @@ class EasyEnergySensorEntityDescription(SensorEntityDescription): 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 +130,8 @@ class EasyEnergySensorEntityDescription(SensorEntityDescription): 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 +181,14 @@ class EasyEnergySensorEntityDescription(SensorEntityDescription): 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 +206,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 1ae7d5c5b5a728..f6886f6df4efd1 100644 --- a/homeassistant/components/easyenergy/services.py +++ b/homeassistant/components/easyenergy/services.py @@ -2,12 +2,13 @@ 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 +33,22 @@ 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 +59,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() - - if value := dt_util.parse_datetime(date_input): - return value - - raise ServiceValidationError( - "Invalid datetime provided.", - translation_domain=DOMAIN, - translation_key="invalid_date", - translation_placeholders={ - "date": date_input, - }, - ) + return dt_util.now().date(), None + + if date_value := dt_util.parse_date(date_input): + return date_value, None + + 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 +131,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 +142,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, ) - return __serialize_prices(data.timestamp_prices) - data = await coordinator.easyenergy.energy_prices( - start_date=start, - end_date=end, - vat=vat, - ) + prices = data.timestamp_prices + else: + data = await coordinator.easyenergy.energy_prices( + start_date=start_date, + end_date=end_date, + vat=vat, + ) + + 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, + ) - if price_type == PriceType.ENERGY_USAGE: - return __serialize_prices(data.timestamp_usage_prices) - return __serialize_prices(data.timestamp_return_prices) + return __serialize_prices(prices) @callback @@ -150,6 +198,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 3f807666a4baca..1a482327a840af 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/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 878e86888e16d5..4f5e0d7e84844a 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/ecowitt/entity.py b/homeassistant/components/ecowitt/entity.py index d6e268c3578146..114270e98a6878 100644 --- a/homeassistant/components/ecowitt/entity.py +++ b/homeassistant/components/ecowitt/entity.py @@ -24,11 +24,10 @@ def __init__(self, sensor: EcoWittSensor) -> None: 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 296490511cbeab..284677e1396797 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -213,11 +213,13 @@ ), 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 33614bf4f9597a..8084a10bc6be47 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/ekeybionyx/config_flow.py b/homeassistant/components/ekeybionyx/config_flow.py index cdf0538eea50bd..a4a4f759726c99 100644 --- a/homeassistant/components/ekeybionyx/config_flow.py +++ b/homeassistant/components/ekeybionyx/config_flow.py @@ -29,9 +29,11 @@ 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/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index b1c26093cf9d2e..b3743fd32706ed 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -273,7 +273,7 @@ async def _add_sentences() -> None: 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 23ed65ded331e7..e7752bfb3e3764 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -6,7 +6,7 @@ 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 +15,11 @@ ) 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 +80,7 @@ def __init__( 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 a47f039384ca2b..eaaa127c47159e 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -12,6 +12,8 @@ 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 +25,6 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 host: str - port: int serial_number: str mac: str | None = None @@ -70,6 +71,69 @@ async def async_step_zeroconf_confirm( """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/coordinator.py b/homeassistant/components/elgato/coordinator.py index 5e1ba0a64947e5..484b134593c324 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 @@ async def _async_update_data(self) -> 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/helpers.py b/homeassistant/components/elgato/helpers.py new file mode 100644 index 00000000000000..2edcb49d2ed883 --- /dev/null +++ b/homeassistant/components/elgato/helpers.py @@ -0,0 +1,43 @@ +"""Helpers for Elgato.""" + +from __future__ import annotations + +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 429f6d1db018de..45f7302a12b564 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -4,8 +4,6 @@ from typing import Any -from elgato import ElgatoError - from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -14,12 +12,12 @@ 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 +92,13 @@ def is_on(self) -> bool: """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 +131,16 @@ async def async_turn_on(self, **kwargs: Any) -> None: 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 734ad5ec930086..3c521810cdf46d 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 531f0447f708eb..6a8847026a3735 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/strings.json b/homeassistant/components/elgato/strings.json index 18bd156833661d..dcfeb23d9acc9f 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 1b24f621807462..d79acfbd417f20 100644 --- a/homeassistant/components/elgato/switch.py +++ b/homeassistant/components/elgato/switch.py @@ -6,16 +6,16 @@ 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 +92,14 @@ def is_on(self) -> bool | None: """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/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 14bd8c55aebe27..54d6ebcc357202 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -293,7 +293,7 @@ def _keypad_changed(keypad: Element, changeset: dict[str, Any]) -> None: 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/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 2a517aee359292..bc7ed9de582294 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/sensor.py b/homeassistant/components/energy/sensor.py index e228e11d00d777..6c430fab3604df 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -715,6 +715,9 @@ def _update_state(self) -> None: 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 +766,11 @@ async def async_added_to_hass(self) -> None: # 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/energyid/quality_scale.yaml b/homeassistant/components/energyid/quality_scale.yaml index be2dd37d6fc57f..ff4ed64e2c9607 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/epic_games_store/manifest.json b/homeassistant/components/epic_games_store/manifest.json index 665eaec6668b92..ea4e0c2f928023 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/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 26814ae18a3d2e..1b3e42e68c17e0 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -8,18 +8,25 @@ 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 +41,51 @@ 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/const.py b/homeassistant/components/esphome/const.py index 32ba27ded8e1cd..a2cb2177bd8137 100644 --- a/homeassistant/components/esphome/const.py +++ b/homeassistant/components/esphome/const.py @@ -1,11 +1,20 @@ """ESPHome constants.""" -from typing import Final +from __future__ import annotations + +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/domain_data.py b/homeassistant/components/esphome/domain_data.py index 2a323d47a061c3..5aa2d8f3e5a68e 100644 --- a/homeassistant/components/esphome/domain_data.py +++ b/homeassistant/components/esphome/domain_data.py @@ -4,12 +4,11 @@ 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 +35,9 @@ def get_or_create_store( ), ) - @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/entry_data.py b/homeassistant/components/esphome/entry_data.py index 46059407294f8d..cac4eadfe25483 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -35,6 +35,7 @@ MediaPlayerInfo, MediaPlayerSupportedFormat, NumberInfo, + RadioFrequencyInfo, SelectInfo, SensorInfo, SensorState, @@ -88,6 +89,7 @@ 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/infrared.py b/homeassistant/components/esphome/infrared.py index 580831f4aec9ab..34bfdcf6f89121 100644 --- a/homeassistant/components/esphome/infrared.py +++ b/homeassistant/components/esphome/infrared.py @@ -35,11 +35,7 @@ def _on_device_update(self) -> None: @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/manifest.json b/homeassistant/components/esphome/manifest.json index f642dfb56943bd..36dc3d1c835d2a 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/radio_frequency.py b/homeassistant/components/esphome/radio_frequency.py new file mode 100644 index 00000000000000..7aaea22f53d80f --- /dev/null +++ b/homeassistant/components/esphome/radio_frequency.py @@ -0,0 +1,77 @@ +"""Radio Frequency platform for ESPHome.""" + +from __future__ import annotations + +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/serial_proxy.py b/homeassistant/components/esphome/serial_proxy.py new file mode 100644 index 00000000000000..a738462255188d --- /dev/null +++ b/homeassistant/components/esphome/serial_proxy.py @@ -0,0 +1,121 @@ +"""Home Assistant-aware ESPHome serial proxy URI handler for serialx.""" + +from __future__ import annotations + +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/water_heater.py b/homeassistant/components/esphome/water_heater.py index 2f80d018150994..814109460a37b6 100644 --- a/homeassistant/components/esphome/water_heater.py +++ b/homeassistant/components/esphome/water_heater.py @@ -11,6 +11,7 @@ WaterHeaterInfo, WaterHeaterMode, WaterHeaterState, + WaterHeaterStateFlag, ) from homeassistant.components.water_heater import ( @@ -72,6 +73,8 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None: 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 +95,12 @@ def current_operation(self) -> str | None: """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 +137,24 @@ async def async_turn_off(self, **kwargs: Any) -> None: 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/eurotronic_cometblue/__init__.py b/homeassistant/components/eurotronic_cometblue/__init__.py new file mode 100644 index 00000000000000..dd4e8d35741849 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/__init__.py @@ -0,0 +1,82 @@ +"""Comet Blue Bluetooth integration.""" + +from __future__ import annotations + +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 00000000000000..7218251e85d93a --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/button.py @@ -0,0 +1,61 @@ +"""Comet Blue button platform.""" + +from __future__ import annotations + +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 00000000000000..f1bb29f7886254 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/climate.py @@ -0,0 +1,190 @@ +"""Comet Blue climate integration.""" + +from __future__ import annotations + +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 00000000000000..f218ee38e0ffc9 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/config_flow.py @@ -0,0 +1,186 @@ +"""Config flow for CometBlue.""" + +from __future__ import annotations + +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 00000000000000..352baa83b38847 --- /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 00000000000000..44361b05715f73 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/coordinator.py @@ -0,0 +1,140 @@ +"""Provides the DataUpdateCoordinator for Comet Blue.""" + +from __future__ import annotations + +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 00000000000000..e0321e409e6bda --- /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 00000000000000..ce5f503321444f --- /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 00000000000000..1d39f1f8bc5de0 --- /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 00000000000000..7ebb9bc0559d9e --- /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 00000000000000..2185d2e21dce77 --- /dev/null +++ b/homeassistant/components/eurotronic_cometblue/sensor.py @@ -0,0 +1,53 @@ +"""Comet Blue sensor integration.""" + +from __future__ import annotations + +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 00000000000000..e69f84a6776809 --- /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 4ed5a0f1378bf6..4a4914abf8bb0a 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -20,7 +20,7 @@ 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 +44,7 @@ class EventDeviceClass(StrEnum): "DOMAIN", "PLATFORM_SCHEMA", "PLATFORM_SCHEMA_BASE", + "DoorbellEventType", "EventDeviceClass", "EventEntity", "EventEntityDescription", @@ -189,6 +190,21 @@ def state_attributes(self) -> dict[str, Any]: 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 cd6a8b96f7a3bd..5bab5875052635 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 bdf9144761cda5..1b5e349b8f3523 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/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index c2d2e6aad0a976..65641c10c45ce3 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -104,6 +104,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 00000000000000..2283a6919e4db9 --- /dev/null +++ b/homeassistant/components/evohome/button.py @@ -0,0 +1,117 @@ +"""Support for Button entities of the Evohome integration.""" + +from __future__ import annotations + +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 36a51edc3bc8d8..74a8f2b97e2428 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -16,8 +16,6 @@ from evohomeasync2.schemas.const import ( SystemMode as EvoSystemMode, ZoneMode as EvoZoneMode, - ZoneModelType as EvoZoneModelType, - ZoneType as EvoZoneType, ) from homeassistant.components.climate import ( @@ -37,13 +35,22 @@ ) 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 +77,16 @@ 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 +94,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 +170,8 @@ def __init__( """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 +188,38 @@ def __init__( ) 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 +334,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 +347,6 @@ def __init__( """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 +361,26 @@ def __init__( 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 +406,16 @@ async def _set_tcs_mode( ) -> 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 +470,13 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: 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 f601ebbfecbd17..31f0eeea64a827 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -19,16 +19,18 @@ 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 +41,4 @@ class EvoService(StrEnum): RESET_SYSTEM = "reset_system" SET_ZONE_OVERRIDE = "set_zone_override" CLEAR_ZONE_OVERRIDE = "clear_zone_override" + SET_DHW_OVERRIDE = "set_dhw_override" diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py index 33af90089a4898..98e2b3b97df754 100644 --- a/homeassistant/components/evohome/coordinator.py +++ b/homeassistant/components/evohome/coordinator.py @@ -139,6 +139,9 @@ async def call_client_api( 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 0879fe739bc265..4700471d23e83a 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 @@ 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 @@ def __init__( 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 @@ def _handle_coordinator_update(self) -> None: 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 @@ def __init__( """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 @@ def _handle_coordinator_update(self) -> None: 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 00000000000000..f16e33251f7524 --- /dev/null +++ b/homeassistant/components/evohome/helpers.py @@ -0,0 +1,36 @@ +"""Helpers for the Evohome integration.""" + +from __future__ import annotations + +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 440595932f2874..fa688c66a569cd 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 e93ccce1df2145..03a3de1b1c8847 100644 --- a/homeassistant/components/evohome/services.py +++ b/homeassistant/components/evohome/services.py @@ -5,26 +5,53 @@ 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 +64,15 @@ ), } +# 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 +95,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 +210,48 @@ async def force_refresh(call: ServiceCall) -> None: @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 cbf39f9c215707..5acb9610674f5a 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/strings.json b/homeassistant/components/evohome/strings.json index 6e39b24f8a67e4..150c1662bf811d 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_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 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 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.", + "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).", + "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 4da5a826690aad..2a97b81170f2c3 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import timedelta import logging from typing import Any @@ -39,11 +40,12 @@ 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 +70,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 +88,6 @@ def __init__( """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 +96,28 @@ def __init__( 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/fail2ban/__init__.py b/homeassistant/components/fail2ban/__init__.py index cb2716e581d42c..e6af0b95b2da30 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/fan/__init__.py b/homeassistant/components/fan/__init__.py index b9e20e8dc919b5..cd94fe87538c07 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -25,7 +25,6 @@ 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 +87,6 @@ def __init__( ) -@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 2f7e4fca5b9195..e54a077409a798 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/strings.json b/homeassistant/components/fan/strings.json index 51a05b6bf4ccb7..65328d320f5781 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_off": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::fan::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::fan::common::condition_for_name%]" } }, "name": "Fan is off" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::fan::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::fan::common::condition_for_name%]" } }, "name": "Fan is on" @@ -85,24 +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": { @@ -196,6 +191,9 @@ "fields": { "behavior": { "name": "[%key:component::fan::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::fan::common::trigger_for_name%]" } }, "name": "Fan turned off" @@ -205,6 +203,9 @@ "fields": { "behavior": { "name": "[%key:component::fan::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::fan::common::trigger_for_name%]" } }, "name": "Fan turned on" diff --git a/homeassistant/components/fan/triggers.yaml b/homeassistant/components/fan/triggers.yaml index 1f7d9442c42042..1eaab693936e54 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/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py index d4be04deae348a..1c2de5f277f23d 100644 --- a/homeassistant/components/ffmpeg/__init__.py +++ b/homeassistant/components/ffmpeg/__init__.py @@ -20,7 +20,6 @@ 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 +70,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 +77,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/fido/__init__.py b/homeassistant/components/fido/__init__.py index d950d39ef70755..227a03cba32ebc 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/file/services.py b/homeassistant/components/file/services.py index 0cd4aaf9324d11..9e4033148cf01d 100644 --- a/homeassistant/components/file/services.py +++ b/homeassistant/components/file/services.py @@ -9,6 +9,7 @@ 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 @@ 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/firefly_iii/coordinator.py b/homeassistant/components/firefly_iii/coordinator.py index 6b67a657ffd876..eab5d82adefc62 100644 --- a/homeassistant/components/firefly_iii/coordinator.py +++ b/homeassistant/components/firefly_iii/coordinator.py @@ -19,7 +19,7 @@ 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 +79,13 @@ async def _async_setup(self) -> None: 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/strings.json b/homeassistant/components/firefly_iii/strings.json index d3b11743ecf787..d367a68699363f 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/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index f2378797d8d257..6b4e1cadbf345e 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -4,9 +4,12 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from . import api -from .const import FitbitScope +from .const import DOMAIN, FitbitScope from .coordinator import FitbitConfigEntry, FitbitData, FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException from .model import config_from_entry_data @@ -16,11 +19,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: FitbitConfigEntry) -> bool: """Set up fitbit from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) fitbit_api = api.OAuthFitbitApi( hass, session, unit_system=entry.data.get("unit_system") diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index b04310e57063c4..8d44a0b686e6bc 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -7,13 +7,14 @@ 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 .const import FitbitUnitSystem from .exceptions import FitbitApiException, FitbitAuthException -from .model import FitbitProfile _LOGGER = logging.getLogger(__name__) @@ -46,7 +46,7 @@ def __init__( ) -> 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 @@ async def _async_get_fitbit_web_api(self) -> ApiClient: 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 d5b33a731e3240..86794f5a963d5c 100644 --- a/homeassistant/components/fitbit/config_flow.py +++ b/homeassistant/components/fitbit/config_flow.py @@ -85,4 +85,6 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResu ) 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/model.py b/homeassistant/components/fitbit/model.py index c1752616b2f27f..83cc47d21b0162 100644 --- a/homeassistant/components/fitbit/model.py +++ b/homeassistant/components/fitbit/model.py @@ -7,20 +7,6 @@ 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 d8025225df522b..a33610ac84a6c7 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -25,6 +25,7 @@ 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 +537,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/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index 4d9060b998766f..49281a78560661 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -121,5 +121,10 @@ "name": "Water" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index 961be04fd8d5fc..f086bc3dd23c19 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -12,6 +12,7 @@ BluetoothChange, BluetoothScanningMode, BluetoothServiceInfoBleak, + async_discovered_service_info, async_rediscover_address, async_register_callback, ) @@ -131,3 +132,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/flume/sensor.py b/homeassistant/components/flume/sensor.py index 0f0213ec984d95..70ffee8973fc33 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -11,7 +11,7 @@ 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 @@ 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 @@ 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/fluss/__init__.py b/homeassistant/components/fluss/__init__.py index c3d4b347ff52a6..66274257b62deb 100644 --- a/homeassistant/components/fluss/__init__.py +++ b/homeassistant/components/fluss/__init__.py @@ -2,18 +2,14 @@ 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 bc8a90e66c0eb8..7b2009fe04c849 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, diff --git a/homeassistant/components/fluss/manifest.json b/homeassistant/components/fluss/manifest.json index fcd7867ed1a95b..83494d8d77fead 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_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 7515b6b8dfcdaa..cbbe254d891017 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -87,8 +87,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/const.py b/homeassistant/components/flux_led/const.py index 08e1d274ea752a..21b8d4284f3e34 100644 --- a/homeassistant/components/flux_led/const.py +++ b/homeassistant/components/flux_led/const.py @@ -9,8 +9,10 @@ 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_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/discovery.py b/homeassistant/components/flux_led/discovery.py index c3a3c5df3a7fff..88bd043f1ad1cd 100644 --- a/homeassistant/components/flux_led/discovery.py +++ b/homeassistant/components/flux_led/discovery.py @@ -153,8 +153,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 +162,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/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index 7b534b805005a0..a684b766b61a82 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -2,14 +2,26 @@ from __future__ import annotations -from homeassistant.const import Platform +from types import MappingProxyType + +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 +37,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 +79,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 +99,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 031764a0d0a3f5..098c74c95473cf 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -11,11 +11,13 @@ 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 +26,51 @@ 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 +80,129 @@ def async_get_options_flow( """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 +210,89 @@ async def async_step_init( 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 ac80b64b869928..22d0794ba7ee81 100644 --- a/homeassistant/components/forecast_solar/const.py +++ b/homeassistant/components/forecast_solar/const.py @@ -14,3 +14,9 @@ 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 efed954e4900f1..65e699c8f38f2e 100644 --- a/homeassistant/components/forecast_solar/coordinator.py +++ b/homeassistant/components/forecast_solar/coordinator.py @@ -4,7 +4,7 @@ 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 +19,10 @@ CONF_DECLINATION, CONF_INVERTER_SIZE, CONF_MODULES_POWER, + DEFAULT_DAMPING, DOMAIN, LOGGER, + SUBENTRY_TYPE_PLANE, ) type ForecastSolarConfigEntry = ConfigEntry[ForecastSolarDataUpdateCoordinator] @@ -30,6 +32,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 +46,34 @@ def __init__(self, hass: HomeAssistant, entry: ForecastSolarConfigEntry) -> None ) 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 cb33ac5dc5a982..80e412dd1a8c7d 100644 --- a/homeassistant/components/forecast_solar/diagnostics.py +++ b/homeassistant/components/forecast_solar/diagnostics.py @@ -28,6 +28,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/sensor.py b/homeassistant/components/forecast_solar/sensor.py index 13a4d5c2d232ec..55493103a7ce26 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -27,6 +27,8 @@ 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 b6cc406877fb93..6d0c3b45844925 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/fortios/__init__.py b/homeassistant/components/fortios/__init__.py index 873d6c00c65595..873e363643f5db 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 4360dd031c7581..3ce6d6e902fc5f 100644 --- a/homeassistant/components/fortios/device_tracker.py +++ b/homeassistant/components/fortios/device_tracker.py @@ -1,6 +1,6 @@ """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 diff --git a/homeassistant/components/freebox/config_flow.py b/homeassistant/components/freebox/config_flow.py index 7ca26f7f34ee9a..a7e0f4afc72b73 100644 --- a/homeassistant/components/freebox/config_flow.py +++ b/homeassistant/components/freebox/config_flow.py @@ -44,6 +44,8 @@ async def async_step_user( 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/manifest.json b/homeassistant/components/freebox/manifest.json index 50c1ea96d9a6c3..0558be9d4712af 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/freshr/__init__.py b/homeassistant/components/freshr/__init__.py index 52d62cff7589e4..7d9938dcd4cdf0 100644 --- a/homeassistant/components/freshr/__init__.py +++ b/homeassistant/components/freshr/__init__.py @@ -3,7 +3,7 @@ import asyncio from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from .coordinator import ( FreshrConfigEntry, @@ -21,10 +21,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo await devices_coordinator.async_config_entry_first_refresh() readings: dict[str, FreshrReadingsCoordinator] = { - device.id: FreshrReadingsCoordinator( + device_id: FreshrReadingsCoordinator( hass, entry, device, devices_coordinator.client ) - for device in devices_coordinator.data + for device_id, device in devices_coordinator.data.items() } await asyncio.gather( *( @@ -38,6 +38,35 @@ async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bo readings=readings, ) + known_devices: set[str] = set(readings) + + @callback + def _handle_coordinator_update() -> None: + current = set(devices_coordinator.data) + removed_ids = known_devices - current + if removed_ids: + known_devices.difference_update(removed_ids) + for device_id in removed_ids: + entry.runtime_data.readings.pop(device_id, None) + new_ids = current - known_devices + if not new_ids: + return + known_devices.update(new_ids) + for device_id in new_ids: + device = devices_coordinator.data[device_id] + readings_coordinator = FreshrReadingsCoordinator( + hass, entry, device, devices_coordinator.client + ) + entry.runtime_data.readings[device_id] = readings_coordinator + hass.async_create_task( + readings_coordinator.async_refresh(), + name=f"freshr_readings_refresh_{device_id}", + ) + + entry.async_on_unload( + devices_coordinator.async_add_listener(_handle_coordinator_update) + ) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) return True diff --git a/homeassistant/components/freshr/config_flow.py b/homeassistant/components/freshr/config_flow.py index e3d366ff03dfda..90c5dd21420e0c 100644 --- a/homeassistant/components/freshr/config_flow.py +++ b/homeassistant/components/freshr/config_flow.py @@ -30,22 +30,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 +67,34 @@ async def async_step_user( 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 +109,11 @@ async def async_step_reauth_confirm( 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 3f68f218687b87..b5e9e633dd7a10 100644 --- a/homeassistant/components/freshr/coordinator.py +++ b/homeassistant/components/freshr/coordinator.py @@ -6,17 +6,24 @@ 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 from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 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) @@ -32,7 +39,7 @@ class FreshrData: type FreshrConfigEntry = ConfigEntry[FreshrData] -class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]): +class FreshrDevicesCoordinator(DataUpdateCoordinator[dict[str, DeviceSummary]]): """Coordinator that refreshes the device list once an hour.""" config_entry: FreshrConfigEntry @@ -48,7 +55,7 @@ def __init__(self, hass: HomeAssistant, config_entry: FreshrConfigEntry) -> None ) self.client = FreshrClient(session=async_create_clientsession(hass)) - async def _async_update_data(self) -> list[DeviceSummary]: + async def _async_update_data(self) -> dict[str, DeviceSummary]: """Fetch the list of devices from the Fresh-r API.""" username = self.config_entry.data[CONF_USERNAME] password = self.config_entry.data[CONF_PASSWORD] @@ -68,8 +75,23 @@ async def _async_update_data(self) -> list[DeviceSummary]: translation_domain=DOMAIN, translation_key="cannot_connect", ) from err - else: - return devices + + current = {device.id: device for device in devices} + + if self.data is not None: + stale_ids = set(self.data) - set(current) + if stale_ids: + device_registry = dr.async_get(self.hass) + for device_id in stale_ids: + if device := device_registry.async_get_device( + identifiers={(DOMAIN, device_id)} + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + + return current class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]): @@ -94,6 +116,12 @@ def __init__( ) 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 00000000000000..a3f37a9f5cb0c4 --- /dev/null +++ b/homeassistant/components/freshr/diagnostics.py @@ -0,0 +1,34 @@ +"""Diagnostics support for Fresh-r.""" + +from __future__ import annotations + +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 00000000000000..b8e94e9f618c37 --- /dev/null +++ b/homeassistant/components/freshr/entity.py @@ -0,0 +1,18 @@ +"""Base entity for the Fresh-r integration.""" + +from __future__ import annotations + +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 7f5d2ab81ac482..0dad2dd7cb2780 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 f8d2b1a97d7306..1d3ae24ae3ecce 100644 --- a/homeassistant/components/freshr/quality_scale.yaml +++ b/homeassistant/components/freshr/quality_scale.yaml @@ -41,11 +41,13 @@ 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. - discovery: todo + discovery: + status: exempt + comment: No local network discovery of devices is possible (no zeroconf, mdns or other discovery mechanisms). docs-data-update: done docs-examples: done docs-known-limitations: done @@ -53,18 +55,18 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + 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 + reconfiguration-flow: done repair-issues: status: exempt comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow. - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/freshr/sensor.py b/homeassistant/components/freshr/sensor.py index 210c3fccf08bdc..84ffd977653ff4 100644 --- a/homeassistant/components/freshr/sensor.py +++ b/homeassistant/components/freshr/sensor.py @@ -20,13 +20,11 @@ UnitOfTemperature, UnitOfVolumeFlowRate, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo +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 FreshrConfigEntry, FreshrReadingsCoordinator +from .entity import FreshrEntity PARALLEL_UPDATES = 0 @@ -93,12 +91,6 @@ class FreshrSensorEntityDescription(SensorEntityDescription): 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), @@ -112,44 +104,51 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Fresh-r sensors from a config entry.""" - entities: list[FreshrSensor] = [] - for device in config_entry.runtime_data.devices.data: - 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, + coordinator = config_entry.runtime_data.devices + known_devices: set[str] = set() + + @callback + def _check_devices() -> None: + current = set(coordinator.data) + removed_ids = known_devices - current + if removed_ids: + known_devices.difference_update(removed_ids) + new_ids = current - known_devices + if not new_ids: + return + known_devices.update(new_ids) + entities: list[FreshrSensor] = [] + for device_id in new_ids: + device = coordinator.data[device_id] + descriptions = SENSOR_TYPES.get( + device.device_type, SENSOR_TYPES[DeviceType.FRESH_R] ) - for description in descriptions - ) - async_add_entities(entities) + entities.extend( + FreshrSensor( + config_entry.runtime_data.readings[device_id], + description, + ) + for description in descriptions + ) + async_add_entities(entities) + + _check_devices() + 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 ee833d999c9cc9..f7627054914f44 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/button.py b/homeassistant/components/fritz/button.py index af5c1b0e8699aa..25f9449696eaaa 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -14,11 +14,12 @@ ) 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 +45,7 @@ class FritzButtonDescription(ButtonEntityDescription): 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 +65,65 @@ class FritzButtonDescription(ButtonEntityDescription): 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 +139,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 +159,9 @@ def async_update_avm_device() -> None: ) ) + repair_issue_cleanup(hass, avm_wrapper) + repair_issue_firmware_update(hass, avm_wrapper) + class FritzButton(ButtonEntity): """Defines a Fritz!Box base button.""" @@ -126,6 +188,18 @@ def __init__( 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 cd8dda57402618..971a88773c0cd9 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -198,7 +198,7 @@ async def async_step_ssdp( 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 604d3f94bf9896..5050907c1d8754 100644 --- a/homeassistant/components/fritz/const.py +++ b/homeassistant/components/fritz/const.py @@ -66,8 +66,6 @@ class MeshRoles(StrEnum): BUTTON_TYPE_WOL = "WakeOnLan" -UPTIME_DEVIATION = 5 - FRITZ_EXCEPTIONS = ( ConnectionError, FritzActionError, @@ -80,6 +78,5 @@ class MeshRoles(StrEnum): 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 3cc797d48569f4..686f5d98c71c7d 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -332,7 +332,10 @@ async def _async_update_data(self) -> 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 +379,8 @@ def mac(self) -> str: """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 @@ -690,7 +695,7 @@ async def async_trigger_cleanup(self) -> None: _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/manifest.json b/homeassistant/components/fritz/manifest.json index 8688eddbdab708..a23c1697456f05 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/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml index 547ef63ad22a5c..3eec68bea5fb18 100644 --- a/homeassistant/components/fritz/quality_scale.yaml +++ b/homeassistant/components/fritz/quality_scale.yaml @@ -34,23 +34,17 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: todo + discovery-update-info: done discovery: done docs-data-update: done docs-examples: done docs-known-limitations: status: exempt comment: no known limitations, yet - docs-supported-devices: - status: todo - comment: add the known supported devices - docs-supported-functions: - status: todo - comment: need to be overhauled + docs-supported-devices: done + docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: - status: todo - comment: need to be overhauled + docs-use-cases: done dynamic-devices: done entity-category: done entity-device-class: done @@ -62,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 41fa3fca056dca..13fc8efff497c2 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta import logging +from fritzconnection.core.exceptions import FritzConnectionException from fritzconnection.lib.fritzstatus import FritzStatus from requests.exceptions import RequestException @@ -28,7 +29,7 @@ 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 @@ -39,31 +40,18 @@ 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: @@ -158,7 +146,7 @@ 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: + except RequestException, IndexError, FritzConnectionException: _LOGGER.debug("CPU temperature not supported by the device") return False if cpu_temp == 0: @@ -200,7 +188,7 @@ class FritzDeviceSensorEntityDescription( 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, ), @@ -307,8 +295,7 @@ class FritzDeviceSensorEntityDescription( DEVICE_SENSOR_TYPES: tuple[FritzDeviceSensorEntityDescription, ...] = ( FritzDeviceSensorEntityDescription( key="device_uptime", - translation_key="device_uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, value_fn=_retrieve_device_uptime_state, ), diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index 9d7d6b339b2aa8..193463233f9488 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -13,7 +13,10 @@ 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 c2aa92818b1ce2..67326ae4ea2c4b 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 61255e27a4dfe5..dd91c1a966ba5d 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -9,6 +9,7 @@ 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 +23,8 @@ 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 +36,101 @@ # 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 +221,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 +293,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 +624,11 @@ def __init__( ) 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/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index 693d8bac5665e6..8ba6fbd5f86422 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -97,7 +97,7 @@ def __init__( super().__init__(coordinator, ain) @callback - def async_write_ha_state(self) -> None: + def _async_write_ha_state(self) -> None: """Write the state to the HASS state machine.""" if self.data.holiday_active: self._attr_supported_features = ClimateEntityFeature.PRESET_MODE @@ -109,7 +109,7 @@ def async_write_ha_state(self) -> None: self._attr_supported_features = SUPPORTED_FEATURES self._attr_hvac_modes = HVAC_MODES self._attr_preset_modes = PRESET_MODES - return super().async_write_ha_state() + return super()._async_write_ha_state() @property def current_temperature(self) -> float: diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index 3f66b43cc0c1a8..d173c8a27f7719 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -148,7 +148,7 @@ async def async_step_ssdp( 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/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 6531f80ddaf491..6366fbfb2d1a56 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -34,7 +34,7 @@ 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 +354,6 @@ def to_response( return response -@bind_hass @callback def async_register_built_in_panel( hass: HomeAssistant, @@ -393,7 +392,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 +407,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 +603,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 f5ba1caabf7239..a2199ee5f4d13d 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.8"] + "requirements": ["home-assistant-frontend==20260429.3"] } diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py index 71196c13f6804f..5ea8bf6e5683b1 100644 --- a/homeassistant/components/frontier_silicon/__init__.py +++ b/homeassistant/components/frontier_silicon/__init__.py @@ -4,7 +4,7 @@ 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 9bad880a9b32fa..89b7c80b3901ab 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 dc4f6bea989e7c..37d1194e9ff26d 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -7,12 +7,7 @@ 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 +111,12 @@ async def async_step_ssdp( 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 +139,7 @@ async def _async_step_device_config_if_needed(self) -> ConfigFlowResult: 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 +147,7 @@ async def _async_step_device_config_if_needed(self) -> ConfigFlowResult: 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 +196,7 @@ async def async_step_device_config( except FSConnectionError: errors["base"] = "cannot_connect" - except InvalidPinException: + except InvalidPinError: errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected exception") @@ -215,7 +210,7 @@ async def async_step_device_config( 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 2a3fc0255e6568..fb00e846d1b495 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -6,7 +6,8 @@ "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["afsapi==0.3.1"], + "loggers": ["afsapi"], + "requirements": ["afsapi==1.0.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 1a85245933a61d..7de4ea455fb5a0 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -2,13 +2,18 @@ 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 +24,13 @@ 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 +39,37 @@ _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 +98,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 +122,45 @@ def __init__(self, unique_id: str, name: str | None, afsapi: AFSAPI) -> None: 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 +168,13 @@ async def async_update(self) -> None: 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 +184,9 @@ async def async_update(self) -> None: 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 +202,38 @@ async def async_update(self) -> None: } self._attr_source_list = list(self.__modes_by_label) + try: + self.__play_caps = await afsapi.get_play_caps() + except FSNotImplementedError: + self.__play_caps = self._FALLBACK_PLAY_CAPS + + if self.__play_caps & (PlayCaps.REPEAT | PlayCaps.REPEAT_ONE): + try: + repeat_mode = await afsapi.get_play_repeat() + except FSNotImplementedError: + self._attr_repeat = RepeatMode.OFF + else: + self._attr_repeat = { + PlayRepeatMode.OFF: RepeatMode.OFF, + PlayRepeatMode.REPEAT_ALL: RepeatMode.ALL, + PlayRepeatMode.REPEAT_ONE: RepeatMode.ONE, + }.get(repeat_mode, RepeatMode.OFF) + else: + self._attr_repeat = RepeatMode.OFF + + if self.__play_caps & PlayCaps.SHUFFLE: + try: + self._attr_shuffle = bool(await afsapi.get_play_shuffle()) + except FSNotImplementedError: + self._attr_shuffle = False + else: + self._attr_shuffle = False + if not self._attr_sound_mode_list and self._supports_sound_mode: try: equalisers = await afsapi.get_equalisers() - except FSNotImplementedException: + except FSNotImplementedError: self._supports_sound_mode = False - # Remove SELECT_SOUND_MODE from the advertised supported features - self._attr_supported_features ^= ( - MediaPlayerEntityFeature.SELECT_SOUND_MODE - ) else: self.__sound_modes_by_label = { sound_mode.label: sound_mode.key for sound_mode in equalisers @@ -166,15 +260,26 @@ async def async_update(self) -> None: self._attr_is_volume_muted = await afsapi.get_mute() self._attr_media_image_url = await afsapi.get_play_graphic() + if self.__play_caps and self.__play_caps & PlayCaps.SEEK: + position_ms = await afsapi.get_play_position() + duration_ms = await afsapi.get_play_duration() + self._attr_media_position = ( + position_ms // 1000 if position_ms is not None else None + ) + self._attr_media_duration = ( + duration_ms // 1000 if duration_ms is not None else None + ) + self._attr_media_position_updated_at = dt_util.utcnow() + else: + self._attr_media_position = None + self._attr_media_duration = None + self._attr_media_position_updated_at = None + if self._supports_sound_mode: try: eq_preset = await afsapi.get_eq_preset() - except FSNotImplementedException: + except FSNotImplementedError: self._supports_sound_mode = False - # Remove SELECT_SOUND_MODE from the advertised supported features - self._attr_supported_features ^= ( - MediaPlayerEntityFeature.SELECT_SOUND_MODE - ) else: self._attr_sound_mode = ( eq_preset.label if eq_preset is not None else None @@ -194,69 +299,82 @@ async def async_update(self) -> None: 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 +384,7 @@ async def async_select_source(self, source: str) -> None: ): await self.fs_device.set_mode(mode) + @fs_command_exception_wrap async def async_select_sound_mode(self, sound_mode: str) -> None: """Select EQ Preset.""" if ( @@ -274,6 +393,27 @@ async def async_select_sound_mode(self, sound_mode: str) -> None: ): await self.fs_device.set_eq_preset(mode) + @fs_command_exception_wrap + async def async_set_repeat(self, repeat: RepeatMode) -> None: + """Set repeat mode.""" + await self.fs_device.play_repeat( + { + RepeatMode.OFF: PlayRepeatMode.OFF, + RepeatMode.ALL: PlayRepeatMode.REPEAT_ALL, + RepeatMode.ONE: PlayRepeatMode.REPEAT_ONE, + }.get(repeat, PlayRepeatMode.OFF) + ) + + @fs_command_exception_wrap + async def async_set_shuffle(self, shuffle: bool) -> None: + """Set shuffle mode.""" + await self.fs_device.set_play_shuffle(shuffle) + + @fs_command_exception_wrap + async def async_media_seek(self, position: float) -> None: + """Seek to a position in seconds.""" + await self.fs_device.set_play_position(int(position * 1000)) + async def async_browse_media( self, media_content_type: MediaType | str | None = None, @@ -285,6 +425,7 @@ async def async_browse_media( 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 cc13e2d0d47e08..fe18bb9264614f 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/fumis/__init__.py b/homeassistant/components/fumis/__init__.py new file mode 100644 index 00000000000000..e04b1b1527d21e --- /dev/null +++ b/homeassistant/components/fumis/__init__.py @@ -0,0 +1,32 @@ +"""Support for Fumis pellet stoves.""" + +from __future__ import annotations + +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, +] + + +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 00000000000000..de533a958355ec --- /dev/null +++ b/homeassistant/components/fumis/binary_sensor.py @@ -0,0 +1,76 @@ +"""Support for Fumis binary sensor entities.""" + +from __future__ import annotations + +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 00000000000000..c6fa30223a687a --- /dev/null +++ b/homeassistant/components/fumis/button.py @@ -0,0 +1,71 @@ +"""Support for Fumis button entities.""" + +from __future__ import annotations + +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 00000000000000..7d14be18238883 --- /dev/null +++ b/homeassistant/components/fumis/climate.py @@ -0,0 +1,128 @@ +"""Support for Fumis climate entities.""" + +from __future__ import annotations + +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 00000000000000..bb1124442ae5ca --- /dev/null +++ b/homeassistant/components/fumis/config_flow.py @@ -0,0 +1,204 @@ +"""Config flow to configure the Fumis integration.""" + +from __future__ import annotations + +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 00000000000000..5cc8f5ac2374c5 --- /dev/null +++ b/homeassistant/components/fumis/const.py @@ -0,0 +1,11 @@ +"""Constants for the Fumis integration.""" + +from __future__ import annotations + +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 00000000000000..2848c3984e3c8f --- /dev/null +++ b/homeassistant/components/fumis/coordinator.py @@ -0,0 +1,71 @@ +"""DataUpdateCoordinator for Fumis.""" + +from __future__ import annotations + +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 00000000000000..91cfb154ab8402 --- /dev/null +++ b/homeassistant/components/fumis/diagnostics.py @@ -0,0 +1,21 @@ +"""Diagnostics support for Fumis.""" + +from __future__ import annotations + +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 00000000000000..5336a665169381 --- /dev/null +++ b/homeassistant/components/fumis/entity.py @@ -0,0 +1,35 @@ +"""Base entity for the Fumis integration.""" + +from __future__ import annotations + +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 00000000000000..a6d9937a50e0ac --- /dev/null +++ b/homeassistant/components/fumis/helpers.py @@ -0,0 +1,63 @@ +"""Helpers for Fumis.""" + +from __future__ import annotations + +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 00000000000000..f8298d2b1da653 --- /dev/null +++ b/homeassistant/components/fumis/icons.json @@ -0,0 +1,104 @@ +{ + "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" + } + } + } +} diff --git a/homeassistant/components/fumis/manifest.json b/homeassistant/components/fumis/manifest.json new file mode 100644 index 00000000000000..ad090be6412f3e --- /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 00000000000000..c966ba5d248c3b --- /dev/null +++ b/homeassistant/components/fumis/number.py @@ -0,0 +1,97 @@ +"""Support for Fumis number entities.""" + +from __future__ import annotations + +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 00000000000000..2bf005be7da65e --- /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 00000000000000..024096048f8f91 --- /dev/null +++ b/homeassistant/components/fumis/sensor.py @@ -0,0 +1,334 @@ +"""Support for Fumis sensor entities.""" + +from __future__ import annotations + +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 00000000000000..8332b1f5d3e7dd --- /dev/null +++ b/homeassistant/components/fumis/strings.json @@ -0,0 +1,207 @@ +{ + "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" + } + } + }, + "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/garage_door/conditions.yaml b/homeassistant/components/garage_door/conditions.yaml index 32215fdc5eb8ff..7782ca40bc6e8d 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 574a117f517911..87eddbf8cc3c50 100644 --- a/homeassistant/components/garage_door/strings.json +++ b/homeassistant/components/garage_door/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_closed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::garage_door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::garage_door::common::condition_for_name%]" } }, "name": "Garage door is closed" @@ -18,26 +23,14 @@ "fields": { "behavior": { "name": "[%key:component::garage_door::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::garage_door::common::condition_for_name%]" } }, "name": "Garage door is open" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Garage door", "triggers": { "closed": { @@ -45,6 +38,9 @@ "fields": { "behavior": { "name": "[%key:component::garage_door::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::garage_door::common::trigger_for_name%]" } }, "name": "Garage door closed" @@ -54,6 +50,9 @@ "fields": { "behavior": { "name": "[%key:component::garage_door::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::garage_door::common::trigger_for_name%]" } }, "name": "Garage door opened" diff --git a/homeassistant/components/garage_door/triggers.yaml b/homeassistant/components/garage_door/triggers.yaml index 5a36582d0dee89..2e6099413ea04d 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/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index 2e915beb22ee56..2345a96997edaa 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio import logging from bleak.backends.device import BLEDevice @@ -13,7 +12,8 @@ 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 +29,6 @@ GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator, ) -from .util import async_get_product_type PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -38,6 +37,7 @@ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.TEXT, Platform.VALVE, ] LOGGER = logging.getLogger(__name__) @@ -75,11 +75,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/config_flow.py b/homeassistant/components/gardena_bluetooth/config_flow.py index 329d8a8fb3be7a..4bb0a8c41b53a4 100644 --- a/homeassistant/components/gardena_bluetooth/config_flow.py +++ b/homeassistant/components/gardena_bluetooth/config_flow.py @@ -9,6 +9,7 @@ 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 +25,27 @@ _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 +77,13 @@ async def async_step_bluetooth( ) -> 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 +120,21 @@ async def async_step_user( 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) + + 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] - self.devices[address] = _get_name(discovery_info) + # 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/icons.json b/homeassistant/components/gardena_bluetooth/icons.json new file mode 100644 index 00000000000000..9ac18776773ce1 --- /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 966a10bc9b0305..08e73c9bf4fe1c 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.1.0"] + "requirements": ["gardena-bluetooth==2.4.0"] } diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index 03c342f7478942..334d8f05dde574 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -4,7 +4,13 @@ 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 +64,18 @@ def context(self) -> set[str]: 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 +131,7 @@ def context(self) -> set[str]: native_min_value=0.0, native_max_value=359.0, native_step=1.0, + entity_category=EntityCategory.CONFIG, char=Spray.sector, ), GardenaBluetoothNumberEntityDescription( @@ -124,6 +143,7 @@ def context(self) -> set[str]: 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 931517e3e4dfa6..9de329529dab63 100644 --- a/homeassistant/components/gardena_bluetooth/select.py +++ b/homeassistant/components/gardena_bluetooth/select.py @@ -13,6 +13,7 @@ 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 +62,7 @@ def context(self) -> set[str]: 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 9cb7316c7832a8..c0a14f66e43dbc 100644 --- a/homeassistant/components/gardena_bluetooth/sensor.py +++ b/homeassistant/components/gardena_bluetooth/sensor.py @@ -8,6 +8,7 @@ from gardena_bluetooth.const import ( AquaContourBattery, + AquaContourWatering, Battery, EventHistory, FlowStatistics, @@ -47,10 +48,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) @@ -171,7 +172,7 @@ def context(self) -> set[str]: 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, @@ -218,7 +219,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) @@ -245,18 +261,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() @@ -271,8 +290,7 @@ def _handle_coordinator_update(self) -> None: 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 8c8815631eb5cf..b7a848d0680f8f 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/text.py b/homeassistant/components/gardena_bluetooth/text.py new file mode 100644 index 00000000000000..ec27759382ebc2 --- /dev/null +++ b/homeassistant/components/gardena_bluetooth/text.py @@ -0,0 +1,88 @@ +"""Support for text entities.""" + +from __future__ import annotations + +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 ce2d862c600d19..00000000000000 --- 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/gate/conditions.yaml b/homeassistant/components/gate/conditions.yaml index aea805c2069f35..ec0b8cf2b77b6e 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 ed1f04b0fc6d07..d40aae3a4ae8e5 100644 --- a/homeassistant/components/gate/strings.json +++ b/homeassistant/components/gate/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_closed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::gate::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::gate::common::condition_for_name%]" } }, "name": "Gate is closed" @@ -18,26 +23,14 @@ "fields": { "behavior": { "name": "[%key:component::gate::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::gate::common::condition_for_name%]" } }, "name": "Gate is open" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Gate", "triggers": { "closed": { @@ -45,6 +38,9 @@ "fields": { "behavior": { "name": "[%key:component::gate::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::gate::common::trigger_for_name%]" } }, "name": "Gate closed" @@ -54,6 +50,9 @@ "fields": { "behavior": { "name": "[%key:component::gate::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::gate::common::trigger_for_name%]" } }, "name": "Gate opened" diff --git a/homeassistant/components/gate/triggers.yaml b/homeassistant/components/gate/triggers.yaml index b50ae440c36915..ed91d2f4246121 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/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 10b24ec17cab46..53cdd3a237b0e8 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -8,6 +8,7 @@ from functools import partial import logging import math +import time from typing import Any import voluptuous as vol @@ -51,6 +52,7 @@ ) 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 +480,7 @@ async def _async_sensor_changed(self, event: Event[EventStateChangedData]) -> No 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 +534,11 @@ def _async_update_temp(self, state: State) -> None: _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 +557,7 @@ async def _async_control_heating( 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 +592,7 @@ async def _async_control_heating( 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 +614,7 @@ async def _async_control_heating( 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 +629,25 @@ def _is_device_active(self) -> bool | None: 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 +671,12 @@ async def _async_heater_turn_on(self, keepalive: bool = False) -> None: 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/github/__init__.py b/homeassistant/components/github/__init__.py index df50039b03f85c..0d86191700bb9b 100644 --- a/homeassistant/components/github/__init__.py +++ b/homeassistant/components/github/__init__.py @@ -2,17 +2,20 @@ 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 +29,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 +44,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 +64,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 a2a7e56830fe59..fd558af2bb29b8 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -19,23 +19,31 @@ 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 .const import CLIENT_ID, CONF_REPOSITORIES, DEFAULT_REPOSITORIES, DOMAIN, LOGGER +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig + +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 +61,7 @@ async def _get_starred_repositories() -> None: 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 +79,7 @@ async def _get_personal_repositories() -> None: 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 +90,26 @@ async def _get_personal_repositories() -> None: ) 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 +119,14 @@ def __init__(self) -> None: 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 +174,7 @@ async def _wait_for_login() -> None: 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 +190,18 @@ async def _wait_for_login() -> None: 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 +211,31 @@ async def async_step_could_not_register( """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 OptionsFlowHandler(OptionsFlowWithReload): - """Handle a option flow for GitHub.""" +class RepositoryFlowHandler(ConfigSubentryFlow): + """Handle repository subentry flow.""" - 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 df44860b780f65..2922f88c07ffeb 100644 --- a/homeassistant/components/github/const.py +++ b/homeassistant/components/github/const.py @@ -15,6 +15,9 @@ 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/diagnostics.py b/homeassistant/components/github/diagnostics.py index 41fef9406a4825..00dfde86977e06 100644 --- a/homeassistant/components/github/diagnostics.py +++ b/homeassistant/components/github/diagnostics.py @@ -21,7 +21,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 +38,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 744fb23001e4e3..a8a1786e0a618b 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -150,13 +150,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 808e87bfe3fd73..7c21e979441a66 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/glances/__init__.py b/homeassistant/components/glances/__init__.py index d7b645d9e115fa..44460ed1928b2a 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/const.py b/homeassistant/components/glances/const.py index f0477a30463844..6831ccb9e3b64a 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 0a9d28883397f7..2c0a845b95fa2f 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 1646b04cedb546..1f04003802b167 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 67f57ee0fbfc77..89c570391889a7 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -1,4 +1,4 @@ -"""Support gathering system information of hosts which are running glances.""" +"""Support gathering system information of hosts which are running Glances.""" from __future__ import annotations @@ -49,6 +49,14 @@ class GlancesSensorEntityDescription(SensorEntityDescription): 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 3d90310366b8fa..9242893a9dc194 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 cde9b5c83674d7..db8cdb41191ec3 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -67,7 +67,7 @@ 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 +175,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 +308,7 @@ async def async_handle_async_webrtc_offer( 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 +354,7 @@ async def async_get_image( """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 +399,19 @@ async def _update_stream_source(self, camera: Camera) -> None: 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/util.py b/homeassistant/components/go2rtc/util.py index 6e47075dbf90be..a19f57f4383243 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/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py index cebff656d5dd0f..60b003ecc2f928 100644 --- a/homeassistant/components/gogogate2/config_flow.py +++ b/homeassistant/components/gogogate2/config_flow.py @@ -75,7 +75,7 @@ async def _async_discovery_handler(self, ip_address: str) -> ConfigFlowResult: 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/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index b658dbca636ba5..f66a656ba39d46 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.4.8"] + "requirements": ["goodwe==0.4.10"] } diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 0f8be7a52e985f..edc7dc50967f76 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -24,6 +24,9 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + ImplementationUnavailableError, +) from homeassistant.helpers.entity import generate_entity_id from .api import ApiAuthImpl, get_feature_access @@ -88,11 +91,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleConfigEntry) -> bo _LOGGER.error("Configuration error in %s: %s", YAML_DEVICES, str(err)) return False - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) # Force a token refresh to fix a bug where tokens were persisted with # expires_in (relative time delta) and expires_at (absolute time) swapped. diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 2660848f8f2267..647c108274d5c5 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -57,6 +57,11 @@ } } }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } + }, "options": { "step": { "init": { @@ -106,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%]", @@ -141,7 +146,7 @@ "name": "Summary" } }, - "name": "Create event" + "name": "Create event in Google Calendar" } } } diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index cfcada03a5c3be..7c33c57a03114b 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -1,4 +1,5 @@ """Support for Actions on Google Assistant Smart Home Control.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/google_assistant/button.py b/homeassistant/components/google_assistant/button.py index 00d809a851ce41..47a390b63355be 100644 --- a/homeassistant/components/google_assistant/button.py +++ b/homeassistant/components/google_assistant/button.py @@ -21,6 +21,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/trait.py b/homeassistant/components/google_assistant/trait.py index 5ae72b7a41ae7a..6c80a69bf4c600 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1076,14 +1076,16 @@ def sync_attributes(self) -> dict[str, Any]: 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 +1238,16 @@ def sync_attributes(self) -> dict[str, Any]: 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 d972a4d8d3fb99..5df6ba19217b41 100644 --- a/homeassistant/components/google_assistant_sdk/__init__.py +++ b/homeassistant/components/google_assistant_sdk/__init__.py @@ -2,14 +2,19 @@ from __future__ import annotations -import aiohttp +from aiohttp import ClientError from gassist_text import TextAssistant from google.oauth2.credentials import Credentials from homeassistant.components import conversation from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import config_validation as cv, discovery, intent from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -58,13 +63,11 @@ async def async_setup_entry( session = OAuth2Session(hass, entry, implementation) try: await session.async_ensure_token_valid() - except aiohttp.ClientResponseError as err: - if 400 <= err.status < 500: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, translation_key="reauth_required" - ) from err - raise ConfigEntryNotReady from err - except aiohttp.ClientError as err: + except OAuth2TokenRequestReauthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="reauth_required" + ) from err + except (OAuth2TokenRequestError, ClientError) as err: raise ConfigEntryNotReady from err mem_storage = InMemoryStorage(hass) diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index b8318436a3a50b..364756cd00a729 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -8,7 +8,6 @@ from typing import Any import uuid -import aiohttp from aiohttp import web from gassist_text import TextAssistant from google.oauth2.credentials import Credentials @@ -26,7 +25,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.exceptions import ( + HomeAssistantError, + OAuth2TokenRequestReauthError, + ServiceValidationError, +) from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.event import async_call_later @@ -79,9 +82,8 @@ async def async_send_text_commands( session = entry.runtime_data.session try: await session.async_ensure_token_valid() - except aiohttp.ClientResponseError as err: - if 400 <= err.status < 500: - entry.async_start_reauth(hass) + except OAuth2TokenRequestReauthError: + entry.async_start_reauth(hass) raise credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call] diff --git a/homeassistant/components/google_drive/diagnostics.py b/homeassistant/components/google_drive/diagnostics.py index 494ec52346f8a5..3391be1c26e34e 100644 --- a/homeassistant/components/google_drive/diagnostics.py +++ b/homeassistant/components/google_drive/diagnostics.py @@ -5,10 +5,7 @@ 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 +23,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_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index ddd9f20377d795..71edb8741d39c2 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -3,38 +3,28 @@ 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 +37,6 @@ 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 +54,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/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index fba51dcd7ef2f8..a347c88a0d6852 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -338,6 +338,7 @@ def _convert_content( async def _transform_stream( + chat_log: conversation.ChatLog, result: AsyncIterator[GenerateContentResponse], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: new_message = True @@ -346,6 +347,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 +637,7 @@ async def _async_handle_chat_log( 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/icons.json b/homeassistant/components/google_generative_ai_conversation/icons.json deleted file mode 100644 index 6ac3cc3b21c57b..00000000000000 --- 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 30077dec6507c3..00000000000000 --- 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 b74babe70859ed..bd5ef1e968f8d9 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_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 844b5efb65eddc..a06cf60d72318a 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -54,6 +54,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 3e455f645ad02e..8162a4d74d0fdb 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 @@ ConfigEntryAuthFailed, ConfigEntryNotReady, HomeAssistantError, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, ) from homeassistant.helpers import config_entry_oauth2_flow @@ -37,24 +38,26 @@ def access_token(self) -> str: 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_photos/__init__.py b/homeassistant/components/google_photos/__init__.py index 08bdce9b359cde..115bd57f67c641 100644 --- a/homeassistant/components/google_photos/__init__.py +++ b/homeassistant/components/google_photos/__init__.py @@ -33,11 +33,18 @@ async def async_setup_entry( hass: HomeAssistant, entry: GooglePhotosConfigEntry ) -> bool: """Set up Google Photos from a config entry.""" - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err + web_session = async_get_clientsession(hass) oauth_session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) auth = api.AsyncConfigEntryAuth(web_session, oauth_session) diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index 63984ecc7c13a2..5295dd6690e70c 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -68,6 +68,9 @@ "no_access_to_path": { "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" }, + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + }, "upload_error": { "message": "Failed to upload content: {message}" } @@ -91,7 +94,7 @@ "name": "Filename" } }, - "name": "Upload media" + "name": "Upload media to Google Photos" } } } diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index 7dfe6bc36129c0..ae5f81c6fc0857 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_weather/config_flow.py b/homeassistant/components/google_weather/config_flow.py index 661146ab01d97e..b03890e8a5297a 100644 --- a/homeassistant/components/google_weather/config_flow.py +++ b/homeassistant/components/google_weather/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -9,6 +10,9 @@ import voluptuous as vol from homeassistant.config_entries import ( + SOURCE_REAUTH, + SOURCE_RECONFIGURE, + SOURCE_USER, ConfigEntry, ConfigEntryState, ConfigFlow, @@ -81,11 +85,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 +115,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 +125,45 @@ async def async_step_user( 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 +173,47 @@ async def async_step_user( }, ], ) + + 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 +226,11 @@ def async_get_supported_subentry_types( 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 +242,35 @@ async def async_step_location( 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 +281,4 @@ async def async_step_location( ) 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 695dc5ea19128a..3c66186394e8d9 100644 --- a/homeassistant/components/google_weather/coordinator.py +++ b/homeassistant/components/google_weather/coordinator.py @@ -12,6 +12,7 @@ CurrentConditionsResponse, DailyForecastResponse, GoogleWeatherApi, + GoogleWeatherApiAuthError, GoogleWeatherApiError, HourlyForecastResponse, ) @@ -19,6 +20,7 @@ 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 +94,14 @@ async def _async_update_data(self) -> 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/manifest.json b/homeassistant/components/google_weather/manifest.json index 4f22a57d875f2e..e7ec2e05563d1e 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 ec5e4edbb4177d..4ae4a8358a3997 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/strings.json b/homeassistant/components/google_weather/strings.json index 977adb306fc021..7b8ab5b060cc0b 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/govee_light_local/const.py b/homeassistant/components/govee_light_local/const.py index a90a1ff1ff1a5c..41ae13d7563167 100644 --- a/homeassistant/components/govee_light_local/const.py +++ b/homeassistant/components/govee_light_local/const.py @@ -11,4 +11,8 @@ 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 0f6ec98814ab2b..8cbb5eb9f0fe07 100644 --- a/homeassistant/components/govee_light_local/light.py +++ b/homeassistant/components/govee_light_local/light.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import datetime import logging from typing import Any @@ -22,7 +23,7 @@ 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 +119,19 @@ def __init__( 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 +219,8 @@ async def async_will_remove_from_hass(self) -> None: @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/green_planet_energy/quality_scale.yaml b/homeassistant/components/green_planet_energy/quality_scale.yaml index 8ac2acdeed8384..acebf2736678d4 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 dac92b8c4e1157..2bb9cc01b33fde 100644 --- a/homeassistant/components/green_planet_energy/sensor.py +++ b/homeassistant/components/green_planet_energy/sensor.py @@ -4,7 +4,7 @@ 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 +36,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 +101,7 @@ class GreenPlanetEnergySensorEntityDescription(SensorEntityDescription): 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 +110,7 @@ class GreenPlanetEnergySensorEntityDescription(SensorEntityDescription): 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 +129,7 @@ class GreenPlanetEnergySensorEntityDescription(SensorEntityDescription): 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/group/__init__.py b/homeassistant/components/group/__init__.py index 5e199e5bcad48d..220ba3713f97fd 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -28,7 +28,6 @@ ) 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 +102,6 @@ def _conf_preprocess(value: Any) -> dict[str, Any]: ) -@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 +115,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/growatt_server/config_flow.py b/homeassistant/components/growatt_server/config_flow.py index bec7e583c26c2d..1914dc21512119 100644 --- a/homeassistant/components/growatt_server/config_flow.py +++ b/homeassistant/components/growatt_server/config_flow.py @@ -8,7 +8,7 @@ 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 @@ async def async_step_user( 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 @@ async def async_step_reauth_confirm( 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 @@ async def async_step_reauth_confirm( 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 @@ async def async_step_reauth_confirm( 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 @@ async def async_step_reauth_confirm( # 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 @@ async def async_step_reauth_confirm( 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 @@ async def async_step_password_auth( _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/quality_scale.yaml b/homeassistant/components/growatt_server/quality_scale.yaml index 48f5168eb4bc3b..15502bdc5b02e3 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/strings.json b/homeassistant/components/growatt_server/strings.json index ee65115f4933fc..4160c5bac84ee4 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/habitica/calendar.py b/homeassistant/components/habitica/calendar.py index 7dd5d5b4675cfd..e0e42359833f29 100644 --- a/homeassistant/components/habitica/calendar.py +++ b/homeassistant/components/habitica/calendar.py @@ -98,7 +98,9 @@ def get_recurrence_dates( 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/hanna/quality_scale.yaml b/homeassistant/components/hanna/quality_scale.yaml index f4eb96842e6d67..405bd7a6c5276d 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/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index a65e58a1b1251e..95b636b4af390a 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -3,14 +3,12 @@ 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 +25,35 @@ 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 +76,6 @@ 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 +88,14 @@ 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 +110,11 @@ 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 +153,8 @@ # wait for the import of the platforms PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE] -CONF_FRONTEND_REPO = "development_repo" - -CONFIG_SCHEMA = vol.Schema( - {vol.Optional(DOMAIN): vol.Schema({vol.Optional(CONF_FRONTEND_REPO): cv.isdir})}, - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) -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 +162,11 @@ ) -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 +190,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 +208,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 +242,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 +299,8 @@ async def push_config(_: Event | None) -> None: 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 +342,14 @@ async def update_info_data(_: datetime | None = None) -> None: 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 +397,7 @@ def _async_setup_hardware_integration(_: datetime | None = None) -> None: # 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 +423,20 @@ def _async_setup_hardware_integration(_: datetime | None = None) -> None: 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 +503,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 f176967923f481..9a4841b4bc9def 100644 --- a/homeassistant/components/hassio/addon_manager.py +++ b/homeassistant/components/hassio/addon_manager.py @@ -26,7 +26,7 @@ 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 +36,15 @@ 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 +53,7 @@ async def wrapper( """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 +62,7 @@ async def wrapper( return wrapper - return handle_hassio_api_error + return handle_supervisor_error @dataclass @@ -128,10 +125,7 @@ def task_in_progress(self) -> bool: ) ) - @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 +142,7 @@ async def async_get_addon_discovery_info(self) -> dict: 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 +190,14 @@ def _async_convert_installed_addon_info( 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 +207,7 @@ async def async_install_addon(self) -> None: 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 +242,22 @@ async def async_update_addon(self) -> None: 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 2a88788a2b5bc8..92dcc6435f91e2 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -8,7 +8,7 @@ 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 @@ def __init__(self, hass: HomeAssistant) -> None: 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 @@ async def post(self, request: web.Request, addon: str) -> web.Response: _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/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index dda9d92bf196bb..1d3a17464bc6e0 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -22,6 +22,7 @@ ATTR_STATE, DATA_KEY_ADDONS, DATA_KEY_MOUNTS, + MAIN_COORDINATOR, ) from .entity import HassioAddonEntity, HassioMountEntity @@ -60,17 +61,18 @@ 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[DATA_KEY_ADDONS].values() for entity_description in ADDON_ENTITY_DESCRIPTIONS ], [ diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 66ffeb9b3c77a0..aa635b7464a943 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -9,8 +9,25 @@ from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: + from aiohasupervisor.models import ( + HomeAssistantInfo, + HostInfo, + InstalledAddon, + 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 @@ 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,34 @@ 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_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 +139,7 @@ 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 679614acbecaa3..176a48ec470de1 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -13,11 +13,17 @@ from aiohasupervisor.models import ( AddonState, CIFSMountResponse, + HomeAssistantInfo, + HostInfo, InstalledAddon, + NetworkInfo, NFSMountResponse, + OSInfo, + ResponseData, + RootInfo, StoreInfo, + SupervisorInfo, ) -from aiohasupervisor.models.base import ResponseData from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME @@ -25,23 +31,26 @@ 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_ADDONS, ATTR_AUTO_UPDATE, + ATTR_DATA, ATTR_REPOSITORIES, ATTR_REPOSITORY, ATTR_SLUG, + ATTR_STARTUP, + ATTR_UPDATE_KEY, ATTR_URL, ATTR_VERSION, - CONTAINER_INFO, + 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, @@ -59,9 +68,15 @@ 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 @@ -74,57 +89,65 @@ @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. @@ -139,11 +162,11 @@ 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. @@ -153,7 +176,6 @@ def get_addons_stats(hass: HomeAssistant) -> dict[str, dict[str, Any] | None]: @callback -@bind_hass def get_core_stats(hass: HomeAssistant) -> dict[str, Any]: """Return core stats. @@ -163,7 +185,6 @@ def get_core_stats(hass: HomeAssistant) -> dict[str, Any]: @callback -@bind_hass def get_supervisor_stats(hass: HomeAssistant) -> dict[str, Any]: """Return supervisor stats. @@ -173,27 +194,26 @@ def get_supervisor_stats(hass: HomeAssistant) -> dict[str, Any]: @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. @@ -318,13 +338,126 @@ def async_remove_devices_from_dev_reg( dev_reg.async_remove_device(dev.id) -class HassioDataUpdateCoordinator(DataUpdateCoordinator): - """Class to retrieve Hass.io status.""" +class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """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) -> dict[str, Any]: + """Update stats data via library.""" + try: + await self._fetch_stats() + except SupervisorError as err: + raise UpdateFailed(f"Error on Supervisor API: {err}") from err + + new_data: dict[str, Any] = {} + new_data[DATA_KEY_CORE] = get_core_stats(self.hass) + new_data[DATA_KEY_SUPERVISOR] = get_supervisor_stats(self.hass) + new_data[DATA_KEY_ADDONS] = get_addons_stats(self.hass) + return new_data + + 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.to_dict() + + # 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, Any] = 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, 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()) + + @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[dict[str, Any]]): + """Class to retrieve Hass.io Add-on status.""" config_entry: ConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: dr.DeviceRegistry + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + dev_reg: dr.DeviceRegistry, + jobs: SupervisorJobs, ) -> None: """Initialize coordinator.""" super().__init__( @@ -332,96 +465,80 @@ def __init__( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=HASSIO_UPDATE_INTERVAL, + update_interval=HASSIO_ADDON_UPDATE_INTERVAL, # We don't want an immediate refresh since we want to avoid - # fetching the container stats right away and avoid hammering - # the Supervisor API on startup + # hammering the Supervisor API on startup request_refresh_debouncer=Debouncer( hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False ), ) - self.hassio = hass.data[DATA_COMPONENT] - self.data = {} self.entry_id = config_entry.entry_id self.dev_reg = dev_reg - self.is_hass_os = (get_info(self.hass) or {}).get("hassos") is not None - self._container_updates: defaultdict[str, dict[str, set[str]]] = defaultdict( - lambda: defaultdict(set) - ) + self._addon_info_subscriptions: defaultdict[str, set[str]] = defaultdict(set) self.supervisor_client = get_supervisor_client(hass) - self.jobs = SupervisorJobs(hass) + self.jobs = jobs async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" is_first_update = not self.data + client = self.supervisor_client try: - await self.force_data_refresh(is_first_update) + 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( + 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 - 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 [] - - if store_data: - repositories = { - repo.slug: repo.name - for repo in StoreInfo.from_dict(store_data).repositories - } + # 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: dict[str, Any] = 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 clean coordinator data + store = self.hass.data.get(DATA_STORE) + if store: + repositories = {repo.slug: repo.name for repo in store.repositories} else: repositories = {} + addons_list_dicts = [addon.to_dict() for addon in installed_addons] + new_data: dict[str, Any] = {} 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: (addon_info_cache.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 + for addon in addons_list_dicts } - 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 addons 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] - ) - async_register_supervisor_in_dev_reg( - self.entry_id, self.dev_reg, new_data[DATA_KEY_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 = { @@ -434,31 +551,11 @@ async def _async_update_data(self) -> dict[str, Any]: if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]): async_remove_devices_from_dev_reg(self.dev_reg, stale_addons) - # Remove mounts that no longer exists from device registry - supervisor_mount_devices = { - device.name - for device in self.dev_reg.devices.get_devices_for_config_entry_id( - self.entry_id - ) - if device.model == SupervisorEntityModel.MOUNT - } - if stale_mounts := supervisor_mount_devices - set(new_data[DATA_KEY_MOUNTS]): - async_remove_devices_from_dev_reg( - self.dev_reg, {f"mount_{stale_mount}" for stale_mount in stale_mounts} - ) - - if not self.is_hass_os and ( - dev := self.dev_reg.async_get_device(identifiers={(DOMAIN, "OS")}) - ): - # 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 + # If there are new add-ons, we should reload the config entry so we can # create new devices and entities. We can return an empty dict 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]) ): self.hass.async_create_task( self.hass.config_entries.async_reload(self.entry_id) @@ -474,110 +571,6 @@ async def get_changelog(self, addon_slug: str) -> str | None: 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: @@ -592,18 +585,17 @@ async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | Non return (slug, info_dict) @callback - def async_enable_container_updates( - self, slug: str, entity_id: str, types: set[str] + def async_enable_addon_info_updates( + self, slug: str, entity_id: 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) + """Enable info updates for an add-on.""" + self._addon_info_subscriptions[slug].add(entity_id) @callback def _remove() -> None: - for key in types: - enabled_updates[key].remove(entity_id) + self._addon_info_subscriptions[slug].discard(entity_id) + if not self._addon_info_subscriptions[slug]: + del self._addon_info_subscriptions[slug] return _remove @@ -616,14 +608,16 @@ async def _async_refresh( ) -> None: """Refresh data.""" if not scheduled and not raise_on_auth_failed: - # Force refreshing updates for non-scheduled updates + # 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.refresh_updates() + await self.supervisor_client.store.reload() except SupervisorError as err: _LOGGER.warning("Error on Supervisor API: %s", err) @@ -643,7 +637,187 @@ async def force_addon_info_data_refresh(self, addon_slug: str) -> None: except SupervisorError as err: _LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err) + +class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to retrieve Hass.io status.""" + + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, dev_reg: dr.DeviceRegistry + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=HASSIO_MAIN_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 + 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 + ) + + @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) -> dict[str, Any]: + """Update data via library.""" + is_first_update = not self.data + client = self.supervisor_client + + try: + # 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 + + # Build clean coordinator data + new_data: dict[str, Any] = {} + new_data[DATA_KEY_CORE] = core_info.to_dict() + new_data[DATA_KEY_SUPERVISOR] = supervisor_info.to_dict() + new_data[DATA_KEY_HOST] = host_info.to_dict() + new_data[DATA_KEY_MOUNTS] = {mount.name: mount for mount in mounts_info.mounts} + if self.is_hass_os: + new_data[DATA_KEY_OS] = os_info.to_dict() + + # 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 + + # If this is the initial refresh, register all main components + if is_first_update: + 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] + ) + async_register_supervisor_in_dev_reg( + self.entry_id, self.dev_reg, new_data[DATA_KEY_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 mounts that no longer exists from device registry + supervisor_mount_devices = { + device.name + for device in self.dev_reg.devices.get_devices_for_config_entry_id( + self.entry_id + ) + if device.model == SupervisorEntityModel.MOUNT + } + if stale_mounts := supervisor_mount_devices - set(new_data[DATA_KEY_MOUNTS]): + async_remove_devices_from_dev_reg( + self.dev_reg, {f"mount_{stale_mount}" for stale_mount in stale_mounts} + ) + + if not self.is_hass_os and ( + dev := self.dev_reg.async_get_device(identifiers={(DOMAIN, "OS")}) + ): + # 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 mounts, we should reload the config entry so we can + # create new devices and entities. We can return an empty dict because + # coordinator will be recreated. + if self.data and ( + set(new_data[DATA_KEY_MOUNTS]) - set(self.data.get(DATA_KEY_MOUNTS, {})) + ): + self.hass.async_create_task( + self.hass.config_entries.async_reload(self.entry_id) + ) + return {} + + return new_data + + 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 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.reload_updates() + 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 + ) + @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 9002310bfcc4e8..76d6adf9b065c0 100644 --- a/homeassistant/components/hassio/diagnostics.py +++ b/homeassistant/components/hassio/diagnostics.py @@ -11,8 +11,12 @@ 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 +24,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) @@ -53,5 +59,7 @@ async def async_get_config_entry_diagnostics( return { "coordinator_data": coordinator.data, + "addons_coordinator_data": addons_coordinator.data, + "stats_coordinator_data": stats_coordinator.data, "devices": devices, } diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py index 1973984d878b0c..58cbccd3769c7f 100644 --- a/homeassistant/components/hassio/discovery.py +++ b/homeassistant/components/hassio/discovery.py @@ -13,7 +13,7 @@ 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 +82,7 @@ def __init__(self, hass: HomeAssistant) -> None: 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 +95,7 @@ async def post(self, request: web.Request, uuid: str) -> web.Response: 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 44ae5a1db646dc..e769430c59608b 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -13,7 +13,6 @@ from .const import ( ATTR_SLUG, CONTAINER_STATS, - CORE_CONTAINER, DATA_KEY_ADDONS, DATA_KEY_CORE, DATA_KEY_HOST, @@ -21,20 +20,79 @@ DATA_KEY_OS, DATA_KEY_SUPERVISOR, DOMAIN, - KEY_TO_UPDATE_TYPES, - SUPERVISOR_CONTAINER, ) -from .coordinator import HassioDataUpdateCoordinator +from .coordinator import ( + HassioAddOnDataUpdateCoordinator, + HassioMainDataUpdateCoordinator, + HassioStatsDataUpdateCoordinator, +) + + +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, + data_key: str, + 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._data_key = data_key + self._attr_unique_id = f"{unique_id_prefix}_{entity_description.key}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)}) + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self._data_key == DATA_KEY_ADDONS: + 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._container_id) or {} + ) + ) + return ( + super().available + and self._data_key in self.coordinator.data + and self.entity_description.key in self.coordinator.data[self._data_key] + ) -class HassioAddonEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): + 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], ) -> None: @@ -56,26 +114,23 @@ def available(self) -> bool: ) 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.""" @@ -94,14 +149,14 @@ def available(self) -> bool: ) -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.""" @@ -120,14 +175,14 @@ def available(self) -> bool: ) -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.""" @@ -146,27 +201,15 @@ def available(self) -> bool: 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.""" @@ -184,27 +227,15 @@ def available(self) -> bool: 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: diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index d0304e3f34d071..8e50841342e4a4 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -14,7 +14,6 @@ from aiohttp.client import ClientTimeout from aiohttp.hdrs import ( AUTHORIZATION, - CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, @@ -25,11 +24,9 @@ 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 +51,7 @@ 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 +69,13 @@ 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 +131,19 @@ async def _handle(self, request: web.Request, path: str) -> web.StreamResponse: """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 +191,7 @@ async def _handle(self, request: web.Request, path: str) -> web.StreamResponse: # 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 +216,13 @@ async def _handle(self, request: web.Request, path: str) -> web.StreamResponse: 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/issues.py b/homeassistant/components/hassio/issues.py index 3d8a9aed6cb9c9..dd8b62862a5763 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -28,7 +28,6 @@ ) from .const import ( - ADDONS_COORDINATOR, ATTR_DATA, ATTR_HEALTHY, ATTR_SLUG, @@ -54,6 +53,7 @@ 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 +62,7 @@ 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 +417,8 @@ def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None: 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/sensor.py b/homeassistant/components/hassio/sensor.py index 9b62faaabcfe2d..16d4ab81f522d2 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -17,20 +17,24 @@ ADDONS_COORDINATOR, ATTR_CPU_PERCENT, ATTR_MEMORY_PERCENT, + ATTR_SLUG, ATTR_VERSION, ATTR_VERSION_LATEST, + CORE_CONTAINER, DATA_KEY_ADDONS, DATA_KEY_CORE, DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, + MAIN_COORDINATOR, + STATS_COORDINATOR, + SUPERVISOR_CONTAINER, ) from .entity import ( HassioAddonEntity, - HassioCoreEntity, HassioHostEntity, HassioOSEntity, - HassioSupervisorEntity, + HassioStatsEntity, ) COMMON_ENTITY_DESCRIPTIONS = ( @@ -63,10 +67,7 @@ ), ) -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 HOST_ENTITY_DESCRIPTIONS = ( SensorEntityDescription( @@ -114,36 +115,64 @@ 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 entity_description in ADDON_ENTITY_DESCRIPTIONS - ] + for addon in addons_coordinator.data[DATA_KEY_ADDONS].values() + for entity_description in COMMON_ENTITY_DESCRIPTIONS + ) + # Add-on stats sensors (cpu_percent, memory_percent) entities.extend( - CoreSensor( - coordinator=coordinator, + HassioStatsSensor( + coordinator=stats_coordinator, entity_description=entity_description, + container_id=addon[ATTR_SLUG], + data_key=DATA_KEY_ADDONS, + device_id=addon[ATTR_SLUG], + unique_id_prefix=addon[ATTR_SLUG], ) - for entity_description in CORE_ENTITY_DESCRIPTIONS + for addon in addons_coordinator.data[DATA_KEY_ADDONS].values() + for entity_description in STATS_ENTITY_DESCRIPTIONS ) + # Core stats sensors entities.extend( - SupervisorSensor( - coordinator=coordinator, + HassioStatsSensor( + coordinator=stats_coordinator, + entity_description=entity_description, + container_id=CORE_CONTAINER, + data_key=DATA_KEY_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, + data_key=DATA_KEY_SUPERVISOR, + device_id="supervisor", + unique_id_prefix="home_assistant_supervisor", ) - for entity_description in SUPERVISOR_ENTITY_DESCRIPTIONS + for entity_description in STATS_ENTITY_DESCRIPTIONS ) + # Host sensors entities.extend( HostSensor( coordinator=coordinator, @@ -152,6 +181,7 @@ async def async_setup_entry( for entity_description in HOST_ENTITY_DESCRIPTIONS ) + # OS sensors if coordinator.is_hass_os: entities.extend( HassioOSSensor( @@ -175,31 +205,26 @@ def native_value(self) -> str: ] -class HassioOSSensor(HassioOSEntity, SensorEntity): - """Sensor to track a Hass.io add-on attribute.""" +class HassioStatsSensor(HassioStatsEntity, SensorEntity): + """Sensor to track container stats.""" @property def native_value(self) -> str: """Return native value of entity.""" - return self.coordinator.data[DATA_KEY_OS][self.entity_description.key] + if self._data_key == DATA_KEY_ADDONS: + return self.coordinator.data[DATA_KEY_ADDONS][self._container_id][ + self.entity_description.key + ] + return self.coordinator.data[self._data_key][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.""" +class HassioOSSensor(HassioOSEntity, SensorEntity): + """Sensor to track a Hass.io OS 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.coordinator.data[DATA_KEY_OS][self.entity_description.key] class HostSensor(HassioHostEntity, SensorEntity): diff --git a/homeassistant/components/hassio/services.py b/homeassistant/components/hassio/services.py new file mode 100644 index 00000000000000..c141015e4a2ba1 --- /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/update.py b/homeassistant/components/hassio/update.py index 5354f21e72635e..92838755cd002e 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -29,6 +29,7 @@ DATA_KEY_CORE, DATA_KEY_OS, DATA_KEY_SUPERVISOR, + MAIN_COORDINATOR, ) from .entity import ( HassioAddonEntity, @@ -51,9 +52,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 +65,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 +73,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[DATA_KEY_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,6 +106,8 @@ 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: @@ -119,6 +134,13 @@ def installed_version(self) -> str | None: """Version installed and in use.""" return self._addon_data[ATTR_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.""" @@ -152,13 +174,34 @@ async def async_install( **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.""" @@ -227,10 +270,29 @@ async def async_install( 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 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: @@ -264,13 +326,58 @@ async def async_install( 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.""" diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 534106c4957a1a..4362eca19859b1 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -18,7 +18,6 @@ async_dispatcher_send, ) -from . import HassioAPIError from .config import HassioUpdateParametersDict from .const import ( ATTR_DATA, @@ -40,6 +39,7 @@ 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 @@ 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 forward_messages(data: dict[str, str]) -> None: @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/hdmi_cec/entity.py b/homeassistant/components/hdmi_cec/entity.py index cc10fd95531bef..582cd4d41cd10c 100644 --- a/homeassistant/components/hdmi_cec/entity.py +++ b/homeassistant/components/hdmi_cec/entity.py @@ -4,6 +4,7 @@ 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 +56,10 @@ def _set_attr_name(self): 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 7ad06f0c45a94d..e4730267c505ca 100644 --- a/homeassistant/components/hdmi_cec/media_player.py +++ b/homeassistant/components/hdmi_cec/media_player.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -from typing import Any from pycec.commands import CecCommand, KeyPressCommand, KeyReleaseCommand from pycec.const import ( @@ -31,7 +30,6 @@ MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, - MediaType, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -45,20 +43,20 @@ 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 +77,61 @@ def send_keypress(self, key): 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 d1bb603a938e2f..63153fd550b348 100644 --- a/homeassistant/components/hdmi_cec/switch.py +++ b/homeassistant/components/hdmi_cec/switch.py @@ -20,10 +20,10 @@ 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 +33,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 +44,19 @@ def __init__(self, device, logical) -> None: 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/hegel/const.py b/homeassistant/components/hegel/const.py index dd684d7db40c95..692ac267e5c12e 100644 --- a/homeassistant/components/hegel/const.py +++ b/homeassistant/components/hegel/const.py @@ -81,6 +81,7 @@ "XLR 2", "Analog 1", "Analog 2", + "Analog 3", "BNC", "Coaxial", "Optical 1", diff --git a/homeassistant/components/hikvisioncam/__init__.py b/homeassistant/components/hikvisioncam/__init__.py index 32a2a86b28fae6..6f832338104997 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/hisense_aehw4a1/__init__.py b/homeassistant/components/hisense_aehw4a1/__init__.py index 3694853fb5ad50..9c60760de4a0fc 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 cd9f3666e0869c..59e21f5419e34c 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -1,4 +1,5 @@ """Pyaehw4a1 platform to control of Hisense AEH-W4A1 Climate Devices.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index b948060fe24fb6..b5fc90f811e1af 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -9,8 +9,10 @@ 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 +85,12 @@ async def get( "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/websocket_api.py b/homeassistant/components/history/websocket_api.py index 3761c935992f54..b42d34385b1e77 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -11,6 +11,8 @@ 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 +140,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 +453,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/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index 3e2d02f153c590..2ca94d7ba5cca8 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -119,9 +119,22 @@ async def async_step_2fa( 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 +186,7 @@ async def async_step_reauth( 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 +233,8 @@ async def async_step_user( 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/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 03f30da74a1866..7bb5d03af95208 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.94", "babel==2.15.0"] + "requirements": ["holidays==0.95", "babel==2.15.0"] } diff --git a/homeassistant/components/home_connect/climate.py b/homeassistant/components/home_connect/climate.py index eda016342e5d7f..eccb3301f9f93b 100644 --- a/homeassistant/components/home_connect/climate.py +++ b/homeassistant/components/home_connect/climate.py @@ -179,13 +179,13 @@ async def async_added_to_hass(self) -> None: 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 @@ def fan_mode(self) -> str | None: """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 9090859456de9e..14e675b7e952cf 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -42,6 +42,7 @@ 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 @@ 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 7b8c04f8d23e32..9c3ad877d994d8 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -533,7 +533,11 @@ async def get_appliance_data(self) -> None: 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 +620,11 @@ async def update_options(self, program_key: ProgramKey) -> None: 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 +637,19 @@ async def update_options(self, program_key: ProgramKey) -> None: 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/fan.py b/homeassistant/components/home_connect/fan.py index 5188fc34daf352..e8410a9aaa20ce 100644 --- a/homeassistant/components/home_connect/fan.py +++ b/homeassistant/components/home_connect/fan.py @@ -84,7 +84,7 @@ def __init__( 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 @@ async def async_added_to_hass(self) -> None: 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 d5955e07e22358..da7dcb7822bfad 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 ee0926768e5f19..164d27ca64456b 100644 --- a/homeassistant/components/home_connect/select.py +++ b/homeassistant/components/home_connect/select.py @@ -436,7 +436,11 @@ def update_native_value(self) -> None: 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 810d7ad356d102..283fc7dfea4b75 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -21,6 +21,7 @@ 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 @@ class HomeConnectSensorEntityDescription( "CookProcessor", "Dishwasher", "Dryer", + "Microwave", "Hood", "Oven", "Washer", @@ -198,7 +200,7 @@ class HomeConnectSensorEntityDescription( 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 @@ class HomeConnectSensorEntityDescription( "Dishwasher", "Washer", "Dryer", + "Microwave", "WasherDryer", "CleaningRobot", "CookProcessor", @@ -599,8 +602,6 @@ async def fetch_unit(self) -> None: 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 @@ async def async_added_to_hass(self) -> None: @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 @@ def available(self) -> bool: 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/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 9583857660fd94..10d4b70e7b7000 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -452,6 +452,16 @@ async def _async_check_deprecation(event: Event) -> None: "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/strings.json b/homeassistant/components/homeassistant/strings.json index dd69ab6b4a6d33..b87ac7d8136304 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/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index 27c63742f7b883..0fc5618c122fb3 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -225,7 +225,7 @@ def update_entity_trigger( 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_hardware/const.py b/homeassistant/components/homeassistant_hardware/const.py index eeeab870514ee8..5d2816dd805e04 100644 --- a/homeassistant/components/homeassistant_hardware/const.py +++ b/homeassistant/components/homeassistant_hardware/const.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import re from typing import TYPE_CHECKING from homeassistant.util.hass_dict import HassKey @@ -37,3 +38,7 @@ 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/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index be6de115b78faa..96e78b80ca3579 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/util.py b/homeassistant/components/homeassistant_hardware/util.py index e8e57b2ae482d5..fa0e4e104ee2ae 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -14,7 +14,12 @@ 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 +31,8 @@ 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 +91,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 +230,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 +265,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") - - if multipan_path is not None: - device_guesses[multipan_path].append( - FirmwareInfo( - device=multipan_path, - firmware_type=ApplicationType.CPC, - firmware_version=None, - source="multiprotocol", - owners=[ - OwningAddon(slug=multipan_addon_manager.addon_slug) - ], - ) + try: + multipan_addon_info = await multipan_addon_manager.async_get_addon_info() + except AddonError: + pass + else: + if multipan_addon_info.state != AddonState.NOT_INSTALLED: + multipan_path = multipan_addon_info.options.get("device") + + if multipan_path is not None: + device_guesses[multipan_path].append( + FirmwareInfo( + device=multipan_path, + firmware_type=ApplicationType.CPC, + firmware_version=None, + source="multiprotocol", + owners=[OwningAddon(slug=multipan_addon_manager.addon_slug)], ) + ) + + # Z2M can be provided by one of many add-ons, we match them by name + 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 943892fc910018..317f8eacf72d53 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -13,7 +13,7 @@ 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 +163,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 +172,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_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index e772c0fe7b3637..26a7b90f3fde07 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -16,8 +16,13 @@ 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 +31,7 @@ from .const import ( FIRMWARE, FIRMWARE_VERSION, + MANUFACTURER, NABU_CASA_FIRMWARE_RELEASES_URL, RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA, @@ -80,6 +86,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/manifest.json b/homeassistant/components/homeassistant_yellow/manifest.json index 31f5b163f9276c..9c69c2ce863331 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/homee/lock.py b/homeassistant/components/homee/lock.py index f061e2eefae50c..724851abe1304c 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 @@ 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 @@ def changed_by(self) -> str: 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 ce08feaaebb1b8..1af5b86b5a18bc 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -979,7 +979,7 @@ def _async_purge_old_bridges( 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/manifest.json b/homeassistant/components/homekit/manifest.json index 7748f86b9acd60..eb06b79490edd4 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/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index ebf1bd97c5be60..783a66ea261537 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -49,14 +49,21 @@ 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 @@ def __init__(self, *args: Any) -> None: ( ATTR_MAX_TEMP, ATTR_MIN_TEMP, + ATTR_OPERATION_LIST, ) ) self._unit = self.hass.config.units.temperature_unit @@ -752,6 +760,20 @@ def __init__(self, *args: Any) -> None: 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 @@ def __init__(self, *args: Any) -> None: 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 @@ def get_temperature_range(self, state: State) -> tuple[float, float]: 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 @@ def async_update_state(self, new_state: State) -> None: # 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/util.py b/homeassistant/components/homekit/util.py index 242422b6f95a7c..993388d42f6ab3 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -625,10 +625,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/const.py b/homeassistant/components/homekit_controller/const.py index 77deb07b3ddd47..fdd34455486de9 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -103,6 +103,7 @@ 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/icons.json b/homeassistant/components/homekit_controller/icons.json index 49ea157a56066c..f1086ec166fee7 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/switch.py b/homeassistant/components/homekit_controller/switch.py index c24a4edf545575..3007ba01aa306b 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -70,6 +70,12 @@ class DeclarativeSwitchEntityDescription(SwitchEntityDescription): 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 41d965fab11064..4c64fdb5f42d7b 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -26,7 +26,9 @@ ) 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 _service_handle_install_mode(service: ServiceCall) -> None: 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/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 30038d1f8977ed..e3c242275429fc 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -1,5 +1,9 @@ """Support for HomematicIP Cloud devices.""" +from __future__ import annotations + +import logging + import voluptuous as vol from homeassistant import config_entries @@ -21,8 +25,11 @@ 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 +92,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 +124,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 config_entry.version == 1: + _LOGGER.debug("Migrating HomematicIP Cloud config entry to version 2") + + # 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) + + @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} - if hap.home.currentAPVersion < "2.2.12": - return + await er.async_migrate_entries(hass, config_entry.entry_id, _update_unique_id) - 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 + hass.config_entries.async_update_entry(config_entry, version=2) + _LOGGER.info("Migration to version 2 successful") - for hapid in hap.home.accessPointUpdateStates: - if er_entry.unique_id == f"HomematicipBatterySensor_{hapid}": - entity_registry.async_remove(er_entry.entity_id) + return True diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index ddfe10fba54b89..1807405ffa00a0 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -42,6 +42,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 +128,4 @@ def available(self) -> bool: @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 6b8aa341ddac3d..7c14056e0fe469 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -4,7 +4,7 @@ from typing import Any -from homematicip.base.enums import SmokeDetectorAlarmType, WindowState +from homematicip.base.enums import LockState, SmokeDetectorAlarmType, WindowState from homematicip.base.functionalChannels import MultiModeInputChannel from homematicip.device import ( AccelerationSensor, @@ -74,6 +74,30 @@ } +def _is_full_flush_lock_controller(device: object) -> bool: + """Return whether the device is an HmIP-FLC.""" + return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr( + device, "functionalChannels" + ) + + +def _get_channel_by_role( + device: object, + functional_channel_type: str, + channel_role: str, +) -> object | None: + """Return the matching functional channel for the device.""" + for channel in getattr(device, "functionalChannels", []): + channel_type = getattr(channel, "functionalChannelType", None) + channel_type_name = getattr(channel_type, "name", channel_type) + if channel_type_name != functional_channel_type: + continue + if getattr(channel, "channelRole", None) != channel_role: + continue + return channel + return None + + async def async_setup_entry( hass: HomeAssistant, config_entry: HomematicIPConfigEntry, @@ -122,6 +146,9 @@ async def async_setup_entry( entities.append( HomematicipPluggableMainsFailureSurveillanceSensor(hap, device) ) + if _is_full_flush_lock_controller(device): + entities.append(HomematicipFullFlushLockControllerLocked(hap, device)) + entities.append(HomematicipFullFlushLockControllerGlassBreak(hap, device)) if isinstance(device, PresenceDetectorIndoor): entities.append(HomematicipPresenceDetector(hap, device)) if isinstance(device, SmokeDetector): @@ -152,7 +179,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: @@ -218,10 +245,18 @@ def extra_state_attributes(self) -> dict[str, Any]: 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.""" @@ -235,6 +270,7 @@ def __init__( channel=1, is_multi_channel=True, channel_real_index=None, + feature_id: str = "contact", ) -> None: """Initialize the multi contact entity.""" super().__init__( @@ -243,6 +279,7 @@ def __init__( channel=channel, is_multi_channel=is_multi_channel, channel_real_index=channel_real_index, + feature_id=feature_id, ) @property @@ -259,7 +296,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): @@ -271,7 +308,9 @@ def __init__( 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 @@ -292,17 +331,74 @@ 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.""" return self._device.motionDetected +class HomematicipFullFlushLockControllerLocked( + HomematicipGenericEntity, BinarySensorEntity +): + """Representation of the HomematicIP full flush lock controller lock state.""" + + _attr_device_class = BinarySensorDeviceClass.LOCK + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the full flush lock controller lock sensor.""" + super().__init__(hap, device, post="Locked", feature_id="lock_locked") + + @property + def is_on(self) -> bool: + """Return true if the controlled lock is locked.""" + channel = _get_channel_by_role( + self._device, + "MULTI_MODE_LOCK_INPUT_CHANNEL", + "DOOR_LOCK_SENSOR", + ) + if channel is None: + return False + lock_state = getattr(channel, "lockState", None) + return getattr(lock_state, "name", lock_state) == LockState.LOCKED.name + + +class HomematicipFullFlushLockControllerGlassBreak( + HomematicipGenericEntity, BinarySensorEntity +): + """Representation of the HomematicIP full flush lock controller glass state.""" + + _attr_device_class = BinarySensorDeviceClass.PROBLEM + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the full flush lock controller glass break sensor.""" + super().__init__(hap, device, post="Glass break", feature_id="glass_break") + + @property + def is_on(self) -> bool: + """Return true if glass break has been detected.""" + channel = _get_channel_by_role( + self._device, + "MULTI_MODE_LOCK_INPUT_CHANNEL", + "DOOR_LOCK_SENSOR", + ) + if channel is None: + return False + return bool(getattr(channel, "glassBroken", False)) + + class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP presence detector.""" _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.""" @@ -314,6 +410,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.""" @@ -334,7 +434,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: @@ -347,6 +449,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.""" @@ -358,7 +464,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: @@ -378,7 +484,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: @@ -393,7 +499,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: @@ -419,7 +525,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: @@ -436,7 +542,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: @@ -449,10 +555,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: @@ -502,7 +614,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 31fa2c889acf40..96ed3ae77e9984 100644 --- a/homeassistant/components/homematicip_cloud/button.py +++ b/homeassistant/components/homematicip_cloud/button.py @@ -12,6 +12,13 @@ from .hap import HomematicIPConfigEntry, HomematicipHAP +def _is_full_flush_lock_controller(device: object) -> bool: + """Return whether the device is an HmIP-FLC.""" + return getattr(device, "modelType", None) == "HmIP-FLC" and hasattr( + device, "send_start_impulse_async" + ) + + async def async_setup_entry( hass: HomeAssistant, config_entry: HomematicIPConfigEntry, @@ -20,11 +27,17 @@ async def async_setup_entry( """Set up the HomematicIP button from a config entry.""" hap = config_entry.runtime_data - async_add_entities( + entities: list[ButtonEntity] = [ HomematicipGarageDoorControllerButton(hap, device) for device in hap.home.devices if isinstance(device, WallMountedGarageDoorController) + ] + entities.extend( + HomematicipFullFlushLockControllerButton(hap, device) + for device in hap.home.devices + if _is_full_flush_lock_controller(device) ) + async_add_entities(entities) class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEntity): @@ -32,9 +45,24 @@ 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: """Handle the button press.""" await self._device.send_start_impulse_async() + + +class HomematicipFullFlushLockControllerButton(HomematicipGenericEntity, ButtonEntity): + """Representation of the HomematicIP full flush lock controller opener.""" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the full flush lock controller opener button.""" + super().__init__( + hap, device, post="Door opener", feature_id="lock_opener_button" + ) + self._attr_icon = "mdi:door-open" + + async def async_press(self) -> None: + """Handle the button press.""" + await self._device.send_start_impulse_async() diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 689bce9243f4ba..881cf4878bb392 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -83,7 +83,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): def __init__(self, hap: HomematicipHAP, device: HeatingGroup) -> None: """Initialize heating group.""" device.modelType = "HmIP-Heating-Group" - super().__init__(hap, device) + super().__init__(hap, device, feature_id="climate") self._simple_heating = None if device.actualTemperature is None: self._simple_heating = self._first_radiator_thermostat diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py index 3a8614b99592e4..144770abfa6364 100644 --- a/homeassistant/components/homematicip_cloud/config_flow.py +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -16,7 +16,7 @@ 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 a8070c455d1aff..e926d2212c2808 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -69,6 +69,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 +157,15 @@ def __init__( 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 +227,11 @@ def __init__( ) -> 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 +282,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 +327,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/entity.py b/homeassistant/components/homematicip_cloud/entity.py index 81f2c7e8c7eb5f..e92b51f92ba4ab 100644 --- a/homeassistant/components/homematicip_cloud/entity.py +++ b/homeassistant/components/homematicip_cloud/entity.py @@ -86,6 +86,8 @@ def __init__( 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 +103,7 @@ def __init__( # 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 +240,10 @@ def available(self) -> bool: @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 f98b078ab73614..a0502f72f54789 100644 --- a/homeassistant/components/homematicip_cloud/event.py +++ b/homeassistant/components/homematicip_cloud/event.py @@ -85,6 +85,7 @@ def __init__( post=description.key, channel=channel, is_multi_channel=False, + feature_id="doorbell", ) self.entity_description = description diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index 6affad00b3fcc9..e311c87904ba8d 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -126,7 +126,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 +147,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 +249,11 @@ def __init__( ) -> 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 +300,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 +352,6 @@ def extra_state_attributes(self) -> dict[str, Any]: 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 +525,7 @@ def __init__( channel=channel_index, is_multi_channel=True, channel_real_index=channel_index, + feature_id="optical_signal_light", ) @property @@ -614,7 +627,13 @@ def __init__( 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 bae075e1a17143..03f26c99a34597 100644 --- a/homeassistant/components/homematicip_cloud/lock.py +++ b/homeassistant/components/homematicip_cloud/lock.py @@ -13,7 +13,7 @@ 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 +53,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 e8192660fe5053..f3ad5828ae5992 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.8.0"] } diff --git a/homeassistant/components/homematicip_cloud/migration.py b/homeassistant/components/homematicip_cloud/migration.py new file mode 100644 index 00000000000000..632a830e9597b5 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/migration.py @@ -0,0 +1,233 @@ +"""Unique ID migration for HomematicIP Cloud entities.""" + +from __future__ import annotations + +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 211dddd881147c..f941616bc22e55 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -383,7 +383,14 @@ def __init__( 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 +412,17 @@ def __init__( 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 +445,7 @@ def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None: channel=channel, post="waterVolumeSinceOpen", attribute="waterVolumeSinceOpen", + feature_id="water_volume_since_open", ) @@ -441,7 +457,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 +474,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 +518,7 @@ def __init__( channel=channel, is_multi_channel=is_multi_channel, post="Valve Position", + feature_id="ftb_valve_position", ) @property @@ -540,7 +557,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 +574,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 +602,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 +619,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 +652,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 +675,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 +706,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 +723,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 +740,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 +772,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 +789,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 +811,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 +833,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 +856,7 @@ def __init__( key: str, value_fn: Callable[[FunctionalChannel], StateType], type_fn: Callable[[FunctionalChannel], str], + feature_id: str, ) -> None: """Initialize Sensor Entity.""" super().__init__( @@ -828,6 +865,7 @@ def __init__( channel=1, post=key, is_multi_channel=False, + feature_id=feature_id, ) self._value_fn = value_fn @@ -862,6 +900,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key="CurrentPowerConsumption", value_fn=lambda channel: channel.currentPowerConsumption, type_fn=lambda channel: "CurrentPowerConsumption", + feature_id="esi_iec_power", ) @@ -880,6 +919,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: 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 +938,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: 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 +957,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: 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 +976,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key="CurrentGasFlow", value_fn=lambda channel: channel.currentGasFlow, type_fn=lambda channel: "CurrentGasFlow", + feature_id="esi_gas_flow", ) @@ -952,6 +995,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key="GasVolume", value_fn=lambda channel: channel.gasVolume, type_fn=lambda channel: "GasVolume", + feature_id="esi_gas_volume", ) @@ -970,6 +1014,7 @@ def __init__(self, hap: HomematicipHAP, device) -> None: key="CurrentPowerConsumption", value_fn=lambda channel: channel.currentPowerConsumption, type_fn=lambda channel: "CurrentPowerConsumption", + feature_id="esi_led_power", ) @@ -988,12 +1033,17 @@ def __init__(self, hap: HomematicipHAP, device) -> None: 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 +1072,9 @@ def __init__( 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 +1099,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 +1125,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/siren.py b/homeassistant/components/homematicip_cloud/siren.py index 5fb4d73a27b35b..d7ee8c577e1569 100644 --- a/homeassistant/components/homematicip_cloud/siren.py +++ b/homeassistant/components/homematicip_cloud/siren.py @@ -60,7 +60,14 @@ def __init__( 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 59216c904a4977..8ec993124c5af2 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -109,7 +109,11 @@ def __init__( ) -> 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 +147,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 a97ec157d170de..d759b7cf242fba 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 061f6642bb221d..623491c0e46635 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -72,7 +72,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 +125,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/honeywell/strings.json b/homeassistant/components/honeywell/strings.json index 2c8e4397b8dee6..b057030ff30fb3 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_string_lights/__init__.py b/homeassistant/components/honeywell_string_lights/__init__.py new file mode 100644 index 00000000000000..f5c7b4b09a5d33 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/__init__.py @@ -0,0 +1,20 @@ +"""The Honeywell String Lights integration.""" + +from __future__ import annotations + +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 00000000000000..f659a1403d4b23 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow for the Honeywell String Lights integration.""" + +from __future__ import annotations + +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 00000000000000..c55c712f6c7a5f --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/const.py @@ -0,0 +1,9 @@ +"""Constants for the Honeywell String Lights integration.""" + +from __future__ import annotations + +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 00000000000000..76363e1efa4278 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/entity.py @@ -0,0 +1,76 @@ +"""Common entity for Honeywell String Lights integration.""" + +from __future__ import annotations + +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 00000000000000..d430e1f90e8c67 --- /dev/null +++ b/homeassistant/components/honeywell_string_lights/light.py @@ -0,0 +1,65 @@ +"""Light platform for Honeywell String Lights.""" + +from __future__ import annotations + +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 00000000000000..62d65edb28e300 --- /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 00000000000000..54bcb3f12c1a0a --- /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 00000000000000..a5c995ace08703 --- /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/hr_energy_qube/binary_sensor.py b/homeassistant/components/hr_energy_qube/binary_sensor.py new file mode 100644 index 00000000000000..71312cbb2115d8 --- /dev/null +++ b/homeassistant/components/hr_energy_qube/binary_sensor.py @@ -0,0 +1,293 @@ +"""Binary sensor platform for Qube Heat Pump.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from python_qube_heatpump.models import QubeState + +from homeassistant.components.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/const.py b/homeassistant/components/hr_energy_qube/const.py index a71233fd8032fa..1f9ea9a820c1fc 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/manifest.json b/homeassistant/components/hr_energy_qube/manifest.json index 7d42cb6d478148..4c279e0ee373cf 100644 --- a/homeassistant/components/hr_energy_qube/manifest.json +++ b/homeassistant/components/hr_energy_qube/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["python_qube_heatpump"], "quality_scale": "bronze", - "requirements": ["python-qube-heatpump==1.7.0"] + "requirements": ["python-qube-heatpump==1.8.0"] } diff --git a/homeassistant/components/hr_energy_qube/strings.json b/homeassistant/components/hr_energy_qube/strings.json index e2b87ed5c74b59..72aec7675124c4 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 ed980a32ceeaf8..5cd10a98a273b4 100644 --- a/homeassistant/components/html5/__init__.py +++ b/homeassistant/components/html5/__init__.py @@ -4,12 +4,21 @@ 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.NOTIFY] +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: diff --git a/homeassistant/components/html5/const.py b/homeassistant/components/html5/const.py index 75826ab90c91d8..cc0aeb282c7ce6 100644 --- a/homeassistant/components/html5/const.py +++ b/homeassistant/components/html5/const.py @@ -7,3 +7,22 @@ ATTR_VAPID_PUB_KEY = "vapid_pub_key" ATTR_VAPID_PRV_KEY = "vapid_prv_key" 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 new file mode 100644 index 00000000000000..71b85208271001 --- /dev/null +++ b/homeassistant/components/html5/entity.py @@ -0,0 +1,73 @@ +"""Base entities for HTML5 integration.""" + +from __future__ import annotations + +from typing import NotRequired, TypedDict + +from aiohttp import ClientSession + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class Keys(TypedDict): + """Types for keys.""" + + p256dh: str + auth: str + + +class Subscription(TypedDict): + """Types for subscription.""" + + endpoint: str + expirationTime: int | None + keys: Keys + + +class Registration(TypedDict): + """Types for registration.""" + + subscription: Subscription + browser: str + name: NotRequired[str] + + +class HTML5Entity(Entity): + """Base entity for HTML5 integration.""" + + _attr_has_entity_name = True + _attr_name = None + _key: str + + def __init__( + self, + config_entry: ConfigEntry, + target: str, + registrations: dict[str, Registration], + session: ClientSession, + json_path: str, + ) -> None: + """Initialize the entity.""" + self.config_entry = config_entry + self.target = target + self.registrations = registrations + self.registration = registrations[target] + self.session = session + self.json_path = json_path + + self._attr_unique_id = f"{config_entry.entry_id}_{target}_{self._key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=target, + model=self.registration["browser"].capitalize(), + identifiers={(DOMAIN, f"{config_entry.entry_id}_{target}")}, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.target in self.registrations diff --git a/homeassistant/components/html5/event.py b/homeassistant/components/html5/event.py new file mode 100644 index 00000000000000..6f74d61d83d0a5 --- /dev/null +++ b/homeassistant/components/html5/event.py @@ -0,0 +1,67 @@ +"""Event platform for HTML5 integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.event import EventEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ATTR_ACTION, ATTR_DATA, ATTR_TAG, DOMAIN, REGISTRATIONS_FILE +from .entity import HTML5Entity +from .notify import _load_config + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the event entity platform.""" + + json_path = hass.config.path(REGISTRATIONS_FILE) + registrations = await hass.async_add_executor_job(_load_config, json_path) + + session = async_get_clientsession(hass) + async_add_entities( + HTML5EventEntity(config_entry, target, registrations, session, json_path) + for target in registrations + ) + + +class HTML5EventEntity(HTML5Entity, EventEntity): + """Representation of an event entity.""" + + _key = "event" + _attr_event_types = ["clicked", "received", "closed"] + _attr_translation_key = "event" + + @callback + def _async_handle_event( + self, target: str, event_type: str, event_data: dict[str, Any] + ) -> None: + """Handle the event.""" + + if target == self.target: + self._trigger_event( + event_type, + { + **event_data.get(ATTR_DATA, {}), + ATTR_ACTION: event_data.get(ATTR_ACTION), + ATTR_TAG: event_data.get(ATTR_TAG), + }, + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register event callback.""" + + self.async_on_remove( + async_dispatcher_connect(self.hass, DOMAIN, self._async_handle_event) + ) diff --git a/homeassistant/components/html5/icons.json b/homeassistant/components/html5/icons.json index d0a6013dd12524..a01f7cf3b72cc8 100644 --- a/homeassistant/components/html5/icons.json +++ b/homeassistant/components/html5/icons.json @@ -1,7 +1,20 @@ { + "entity": { + "event": { + "event": { + "default": "mdi:gesture-tap-button" + } + } + }, "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 00000000000000..66c11f5c74219c --- /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 1ef261d201d860..9a71b05e348f93 100644 --- a/homeassistant/components/html5/manifest.json +++ b/homeassistant/components/html5/manifest.json @@ -1,10 +1,11 @@ { "domain": "html5", "name": "HTML5 Push Notifications", - "codeowners": ["@alexyao2015"], + "codeowners": ["@alexyao2015", "@tr4nt0r"], "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 a5e823ce629cba..c3ea03d01b91ec 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -8,7 +8,7 @@ import json import logging import time -from typing import TYPE_CHECKING, Any, NotRequired, TypedDict, cast +from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse import uuid @@ -38,7 +38,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -46,17 +46,24 @@ 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, DOMAIN, + REGISTRATIONS_FILE, SERVICE_DISMISS, ) +from .entity import HTML5Entity, Registration +from .issue import deprecated_dismiss_action_call, deprecated_notify_action_call _LOGGER = logging.getLogger(__name__) -REGISTRATIONS_FILE = "html5_push_registrations.conf" - ATTR_SUBSCRIPTION = "subscription" ATTR_BROWSER = "browser" @@ -67,15 +74,11 @@ ATTR_P256DH = "p256dh" ATTR_EXPIRATIONTIME = "expirationTime" -ATTR_TAG = "tag" -ATTR_ACTION = "action" -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" @@ -156,29 +159,6 @@ ) -class Keys(TypedDict): - """Types for keys.""" - - p256dh: str - auth: str - - -class Subscription(TypedDict): - """Types for subscription.""" - - endpoint: str - expirationTime: int | None - keys: Keys - - -class Registration(TypedDict): - """Types for registration.""" - - subscription: Subscription - browser: str - name: NotRequired[str] - - async def async_get_service( hass: HomeAssistant, config: ConfigType, @@ -419,7 +399,15 @@ async def post(self, request: web.Request) -> web.Response: ) event_name = f"{NOTIFY_CALLBACK_EVENT}.{event_payload[ATTR_TYPE]}" - request.app[KEY_HASS].bus.fire(event_name, event_payload) + hass = request.app[KEY_HASS] + hass.bus.fire(event_name, event_payload) + async_dispatcher_send( + hass, + DOMAIN, + event_payload[ATTR_TARGET], + event_payload[ATTR_TYPE], + event_payload, + ) return self.json({"status": "ok", "event": event_payload[ATTR_TYPE]}) @@ -472,6 +460,9 @@ async def async_dismiss(self, **kwargs: Any) -> None: 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: {}} @@ -480,6 +471,9 @@ async def async_dismiss(self, **kwargs: Any) -> None: 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, @@ -613,65 +607,65 @@ async def async_setup_entry( ) -class HTML5NotifyEntity(NotifyEntity): +class HTML5NotifyEntity(HTML5Entity, NotifyEntity): """Representation of a notification entity.""" - _attr_has_entity_name = True - _attr_name = None - _attr_supported_features = NotifyEntityFeature.TITLE + _key = "device" - def __init__( + async def async_send_message(self, message: str, title: str | None = None) -> None: + """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, + ) + + 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, - config_entry: ConfigEntry, - target: str, - registrations: dict[str, Registration], - session: ClientSession, - json_path: str, + message: str | None = None, + timestamp: datetime | None = None, + ttl: timedelta | None = None, + urgency: str | None = None, + **kwargs: Any, ) -> None: - """Initialize the entity.""" - self.config_entry = config_entry - self.target = target - self.registrations = registrations - self.registration = registrations[target] - self.session = session - self.json_path = json_path + """Shared internal helper to push messages.""" + payload: dict[str, Any] = kwargs - self._attr_unique_id = f"{config_entry.entry_id}_{target}_device" - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - name=target, - model=self.registration["browser"].capitalize(), - identifiers={(DOMAIN, f"{config_entry.entry_id}_{target}")}, - ) + if message is not None: + payload["body"] = message - 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()) + payload.setdefault(ATTR_TAG, str(uuid.uuid4())) + ts = int(timestamp.timestamp()) if timestamp else int(time.time()) + payload[ATTR_TIMESTAMP] = ts * 1000 - 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"], - ) - }, - } + 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: @@ -680,6 +674,8 @@ async def async_send_message(self, message: str, title: str | None = None) -> No 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() @@ -714,8 +710,3 @@ async def async_send_message(self, message: str, title: str | None = None) -> No translation_key="connection_error", translation_placeholders={"target": self.target}, ) from e - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return super().available and self.target in self.registrations diff --git a/homeassistant/components/html5/services.py b/homeassistant/components/html5/services.py new file mode 100644 index 00000000000000..06b6e5f92a0386 --- /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 929eb5a2dc1251..9ed88b2ba71b21 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 81964a2af95007..3dea89e6ead279 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -20,6 +20,25 @@ } } }, + "entity": { + "event": { + "event": { + "state_attributes": { + "action": { "name": "Action" }, + "event_type": { + "state": { + "clicked": "Clicked", + "closed": "Closed", + "received": "Received" + } + }, + "tag": { + "name": "[%key:component::html5::services::send_message::fields::tag::name%]" + } + } + } + } + }, "exceptions": { "channel_expired": { "message": "Notification channel for {target} has expired" @@ -32,9 +51,45 @@ } }, "issues": { - "deprecated_yaml_import_issue": { - "description": "Configuring HTML5 push notification using YAML has been deprecated. An automatic import of your existing configuration was attempted, but it failed.\n\nPlease remove the HTML5 push notification YAML configuration from your configuration.yaml file and reconfigure HTML5 push notification again manually.", - "title": "HTML5 YAML configuration import failed" + "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": { @@ -51,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 a4db676ffe38b4..27078fe3f532ef 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -51,7 +51,6 @@ 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 +174,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/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index a7bd90baefd287..24328c17059924 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -8,7 +8,7 @@ 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 +63,7 @@ DEFAULT_MANUFACTURER, DEFAULT_NOTIFY_SERVICE_NAME, DOMAIN, + HUAWEI_LTE_CONFIG, KEY_DEVICE_BASIC_INFORMATION, KEY_DEVICE_INFORMATION, KEY_DEVICE_SIGNAL, @@ -107,7 +108,7 @@ class Router: """Class for router state.""" hass: HomeAssistant - config_entry: ConfigEntry + config_entry: HuaweiLteConfigEntry connection: Connection url: str @@ -277,14 +278,10 @@ def cleanup(self, *_: Any) -> None: self.connection.requests_session.close() -class HuaweiLteData(NamedTuple): - """Shared state.""" +type HuaweiLteConfigEntry = ConfigEntry[Router] - hass_config: ConfigType - routers: dict[str, 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 +348,7 @@ def _connect() -> Connection: 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 +413,7 @@ def _connect() -> Connection: 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 +436,16 @@ def _update_router(*_: Any) -> None: 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 +453,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 +461,22 @@ def service_handler(service: ServiceCall) -> None: 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 +506,9 @@ def service_handler(service: ServiceCall) -> None: 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 41f4638b713fab..66bf19815b99ef 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -12,13 +12,12 @@ 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 +29,11 @@ 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 04480a85e03fef..806f58af30084d 100644 --- a/homeassistant/components/huawei_lte/button.py +++ b/homeassistant/components/huawei_lte/button.py @@ -11,12 +11,11 @@ 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 +23,11 @@ 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 120d96e7d78593..426df001e33fc7 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -21,12 +21,7 @@ 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 +42,7 @@ SsdpServiceInfo, ) +from . import HuaweiLteConfigEntry from .const import ( CONF_MANUFACTURER, CONF_TRACK_WIRED_CLIENTS, @@ -76,7 +72,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 bc114f56e9980c..09b61db546d4bb 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 58e61c80bfeea8..5fed0ec141210a 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -9,7 +9,6 @@ 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 +16,10 @@ 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 +48,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 +56,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 975ab476e6cea1..d434c7d6910130 100644 --- a/homeassistant/components/huawei_lte/diagnostics.py +++ b/homeassistant/components/huawei_lte/diagnostics.py @@ -5,10 +5,9 @@ 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 +73,13 @@ 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/manifest.json b/homeassistant/components/huawei_lte/manifest.json index e4f211ffcee5bb..a18af6ff83fd15 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 7543eb71d88c85..0af671e95cbc12 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -12,8 +12,7 @@ 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 +26,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 57fce90fdd6aa2..169dd0c6342fe1 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 43961b4ec7365b..c228053d2c020a 100644 --- a/homeassistant/components/huawei_lte/select.py +++ b/homeassistant/components/huawei_lte/select.py @@ -6,6 +6,7 @@ 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 +15,13 @@ 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 +31,16 @@ 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 aaf71c9195b8cc..1516413f7ca389 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -17,7 +17,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -31,9 +30,8 @@ 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 +793,11 @@ class HuaweiSensorEntityDescription(SensorEntityDescription): 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 ac8bca4234c19f..d6b1ad9f228540 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -10,16 +10,12 @@ 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 +23,11 @@ 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/hue/scene.py b/homeassistant/components/hue/scene.py index 8a4aac098ffa91..9dfa610e343726 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from aiohue.v2 import HueBridgeV2 @@ -29,6 +30,8 @@ ATTR_SPEED = "speed" ATTR_BRIGHTNESS = "brightness" +LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -49,10 +52,18 @@ def async_add_entity( 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_ble/manifest.json b/homeassistant/components/hue_ble/manifest.json index 707594fcde177b..fffc31c3e93f99 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/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 8ee4a1eaf78665..10fc1733f431f0 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -24,7 +24,6 @@ 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 +77,6 @@ class HumidifierDeviceClass(StrEnum): # 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 2a96eaffe376d2..406bdd88b8c771 100644 --- a/homeassistant/components/humidifier/condition.py +++ b/homeassistant/components/humidifier/condition.py @@ -5,7 +5,7 @@ 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 @@ 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 @@ def entity_filter(self, entities: set[str]) -> set[str]: {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 25c29301f26443..4c04906011250a 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/strings.json b/homeassistant/components/humidifier/strings.json index ff7f28a2e5fc31..9838f9a7967e1a 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", - "trigger_behavior_name": "Trigger when" + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_drying": { @@ -10,6 +12,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" } }, "name": "Humidifier is drying" @@ -19,6 +24,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" } }, "name": "Humidifier is humidifying" @@ -29,6 +37,9 @@ "behavior": { "name": "[%key:component::humidifier::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" + }, "mode": { "description": "The operation modes to check for.", "name": "Mode" @@ -41,6 +52,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" } }, "name": "Humidifier is off" @@ -50,6 +64,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" } }, "name": "Humidifier is on" @@ -60,6 +77,9 @@ "behavior": { "name": "[%key:component::humidifier::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::humidifier::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::humidifier::common::condition_threshold_name%]" } @@ -154,21 +174,6 @@ "message": "Provided humidity {humidity} is not valid. Accepted range is {min_humidity} to {max_humidity}." } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "set_humidity": { "description": "Sets the target humidity of a humidifier.", @@ -211,6 +216,9 @@ "behavior": { "name": "[%key:component::humidifier::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::humidifier::common::trigger_for_name%]" + }, "mode": { "description": "The operation modes to trigger on.", "name": "Mode" @@ -223,6 +231,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::trigger_for_name%]" } }, "name": "Humidifier started drying" @@ -232,6 +243,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::trigger_for_name%]" } }, "name": "Humidifier started humidifying" @@ -241,6 +255,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::trigger_for_name%]" } }, "name": "Humidifier turned off" @@ -250,6 +267,9 @@ "fields": { "behavior": { "name": "[%key:component::humidifier::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::humidifier::common::trigger_for_name%]" } }, "name": "Humidifier turned on" diff --git a/homeassistant/components/humidifier/triggers.yaml b/homeassistant/components/humidifier/triggers.yaml index 12072ab71eb1f9..d7ca86262dd8f3 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/condition.py b/homeassistant/components/humidity/condition.py index 101815a4009fec..82412c1bed4ad5 100644 --- a/homeassistant/components/humidity/condition.py +++ b/homeassistant/components/humidity/condition.py @@ -16,9 +16,9 @@ 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 +33,31 @@ ), } + +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 06818a57974ca9..9eac07e935974c 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 20df0ca139a53f..7f51d21f4f4874 100644 --- a/homeassistant/components/humidity/strings.json +++ b/homeassistant/components/humidity/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -12,6 +14,9 @@ "behavior": { "name": "[%key:component::humidity::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::humidity::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::humidity::common::condition_threshold_name%]" } @@ -19,21 +24,6 @@ "name": "Relative humidity" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Humidity", "triggers": { "changed": { @@ -51,6 +41,9 @@ "behavior": { "name": "[%key:component::humidity::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::humidity::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::humidity::common::trigger_threshold_name%]" } diff --git a/homeassistant/components/humidity/trigger.py b/homeassistant/components/humidity/trigger.py index 53347675045601..806bb38c95ba24 100644 --- a/homeassistant/components/humidity/trigger.py +++ b/homeassistant/components/humidity/trigger.py @@ -15,12 +15,13 @@ 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 +39,46 @@ ), } + +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 0b29fcf871f14c..e37cde2b99204d 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/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 1d1619762dfdfd..aa1682923c8835 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -184,10 +184,8 @@ async def client_listen( ) 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/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index aa77ae2f7b727b..203aa52be56103 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.4"] } diff --git a/homeassistant/components/husqvarna_automower_ble/config_flow.py b/homeassistant/components/husqvarna_automower_ble/config_flow.py index d36b89f2d13156..fd88603b53a3d8 100644 --- a/homeassistant/components/husqvarna_automower_ble/config_flow.py +++ b/homeassistant/components/husqvarna_automower_ble/config_flow.py @@ -11,7 +11,8 @@ 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 +38,6 @@ 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 +55,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 +88,7 @@ async def async_step_bluetooth( """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 +112,13 @@ async def async_step_bluetooth_confirm( 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/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index 3c9fb7d57c87af..e3e710a4fed3d6 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.1.0"] + "requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.4.0"] } diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index c3220d261e9465..319f2475ba7785 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any from huum.const import SaunaStatus @@ -18,12 +17,10 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP +from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP, DOMAIN from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator from .entity import HuumBaseEntity -_LOGGER = logging.getLogger(__name__) - PARALLEL_UPDATES = 1 @@ -113,5 +110,7 @@ async def _turn_on(self, temperature: int) -> None: try: await self.coordinator.huum.turn_on(temperature) except (ValueError, SafetyException) as err: - _LOGGER.error(str(err)) - raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unable_to_turn_on", + ) from err diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py index c5cdc18107a1d5..d6c93e9dedb2b3 100644 --- a/homeassistant/components/huum/config_flow.py +++ b/homeassistant/components/huum/config_flow.py @@ -59,6 +59,43 @@ async def async_step_user( 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 532e78a81759aa..fac9f234ea8f13 100644 --- a/homeassistant/components/huum/coordinator.py +++ b/homeassistant/components/huum/coordinator.py @@ -56,5 +56,6 @@ async def _async_update_data(self) -> HuumStatusResponse: return await self.huum.status() except (Forbidden, NotAuthenticated) as err: raise ConfigEntryAuthFailed( - "Could not log in to Huum with given credentials" + translation_domain=DOMAIN, + translation_key="auth_failed", ) from err diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index ed392c8e637589..b7415cbbd724d7 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/huum", "integration_type": "device", "iot_class": "cloud_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["huum==0.8.2"] } diff --git a/homeassistant/components/huum/quality_scale.yaml b/homeassistant/components/huum/quality_scale.yaml index d522814b9bad68..6422498628a0f8 100644 --- a/homeassistant/components/huum/quality_scale.yaml +++ b/homeassistant/components/huum/quality_scale.yaml @@ -39,7 +39,7 @@ rules: log-when-unavailable: done parallel-updates: done reauthentication-flow: done - test-coverage: todo + test-coverage: done # Gold devices: done @@ -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 @@ -62,9 +62,9 @@ rules: status: exempt comment: All entities are core functionality. entity-translations: done - exception-translations: todo + 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/strings.json b/homeassistant/components/huum/strings.json index 9ad89b5daaf443..e7c597838071ab 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%]", @@ -45,5 +56,13 @@ "name": "[%key:component::sensor::entity_component::humidity::name%]" } } + }, + "exceptions": { + "auth_failed": { + "message": "Could not log in to Huum with the given credentials." + }, + "unable_to_turn_on": { + "message": "Unable to turn on the sauna." + } } } diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index d7344f56ab57d0..5a1abf2b02480d 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -23,6 +23,8 @@ 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/entity.py b/homeassistant/components/hydrawise/entity.py index 58153d436345e0..465da27a778e31 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -69,6 +69,10 @@ def _update_attrs(self) -> None: @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 069ca3ef500c24..be00fad48545c4 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 19fcd0295a2cb8..2880ef7ca1aa9c 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -22,6 +22,8 @@ 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 238e249e1f69bf..5ba88d6d7fcbb7 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -22,6 +22,8 @@ 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 56dd56e7d21dd7..9ed55ae9beec11 100644 --- a/homeassistant/components/hydrawise/valve.py +++ b/homeassistant/components/hydrawise/valve.py @@ -19,6 +19,8 @@ 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 60a53193acc828..83385b5ff19541 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -1,4 +1,4 @@ -"""The Hyperion component.""" +"""The Hyperion integration.""" from __future__ import annotations diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index bd96c9667ad582..a839263dd65cb7 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -1,4 +1,4 @@ -"""Switch platform for Hyperion.""" +"""Camera platform for Hyperion.""" from __future__ import annotations diff --git a/homeassistant/components/hypontech/manifest.json b/homeassistant/components/hypontech/manifest.json index 0f417f491c1a21..54cefce5476400 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/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 9a745a61f1fb0f..2c1f64d8aeb4d5 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -4,7 +4,6 @@ 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 +17,25 @@ 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 +59,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 +80,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 +93,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 +181,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 +201,6 @@ async def wrapper( ) -> 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 3c260c7ef037ac..de083852e2ebb3 100644 --- a/homeassistant/components/iaqualink/binary_sensor.py +++ b/homeassistant/components/iaqualink/binary_sensor.py @@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AqualinkConfigEntry +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity PARALLEL_UPDATES = 0 @@ -24,11 +25,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 +37,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 36aec12976acfb..87d1fe88928089 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -19,6 +19,7 @@ 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 +35,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 +52,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 b828c25c945aa6..6536ddd44aab3a 100644 --- a/homeassistant/components/iaqualink/config_flow.py +++ b/homeassistant/components/iaqualink/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any import httpx @@ -12,19 +13,50 @@ ) 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 +64,57 @@ async def async_step_user( 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 00000000000000..1d621afbf2c1b4 --- /dev/null +++ b/homeassistant/components/iaqualink/coordinator.py @@ -0,0 +1,51 @@ +"""Data update coordinator for iaqualink.""" + +from __future__ import annotations + +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 c0f44946b7716b..0a471f9910b63e 100644 --- a/homeassistant/components/iaqualink/entity.py +++ b/homeassistant/components/iaqualink/entity.py @@ -5,26 +5,31 @@ 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 +40,7 @@ def __init__(self, dev: AqualinkDeviceT) -> None: 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 55b14065cef9d0..585d393f3fff4f 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -17,6 +17,7 @@ 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 +31,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 fea0531264ae9c..d977a4c87f645e 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 00000000000000..cefaaca753cd1c --- /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 baeca799bc3c6c..feef41248eab46 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -10,6 +10,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AqualinkConfigEntry +from .coordinator import AqualinkDataUpdateCoordinator from .entity import AqualinkEntity PARALLEL_UPDATES = 0 @@ -22,18 +23,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 5b00a9424de666..23857c34817c14 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 851554a1972971..afb2e88a06ed8d 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -11,6 +11,7 @@ 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 +25,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/illuminance/conditions.yaml b/homeassistant/components/illuminance/conditions.yaml index b23ac8007e0da8..92f43eecd40d62 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 e1c478fff9f2de..59bd195116f46b 100644 --- a/homeassistant/components/illuminance/strings.json +++ b/homeassistant/components/illuminance/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -11,6 +13,9 @@ "fields": { "behavior": { "name": "[%key:component::illuminance::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::illuminance::common::condition_for_name%]" } }, "name": "Light is detected" @@ -20,6 +25,9 @@ "fields": { "behavior": { "name": "[%key:component::illuminance::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::illuminance::common::condition_for_name%]" } }, "name": "Light is not detected" @@ -30,6 +38,9 @@ "behavior": { "name": "[%key:component::illuminance::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::illuminance::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::illuminance::common::condition_threshold_name%]" } @@ -37,21 +48,6 @@ "name": "Illuminance" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Illuminance", "triggers": { "changed": { @@ -68,6 +64,9 @@ "fields": { "behavior": { "name": "[%key:component::illuminance::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::illuminance::common::trigger_for_name%]" } }, "name": "Light cleared" @@ -78,6 +77,9 @@ "behavior": { "name": "[%key:component::illuminance::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::illuminance::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::illuminance::common::trigger_threshold_name%]" } @@ -89,6 +91,9 @@ "fields": { "behavior": { "name": "[%key:component::illuminance::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::illuminance::common::trigger_for_name%]" } }, "name": "Light detected" diff --git a/homeassistant/components/illuminance/triggers.yaml b/homeassistant/components/illuminance/triggers.yaml index c2f77fd4292037..b76bd6fe34f244 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_upload/media_source.py b/homeassistant/components/image_upload/media_source.py index d1fc978c27839f..eb0f11630e7d49 100644 --- a/homeassistant/components/image_upload/media_source.py +++ b/homeassistant/components/image_upload/media_source.py @@ -27,7 +27,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 +79,7 @@ async def async_browse_media( 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/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json index 0265c6c2ec0496..c394679056e081 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 25f7a0caf6bf6e..998cd98bbeb23b 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.1.0"] + "requirements": ["imgw_pib==2.1.1"] } diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 170736d8f6c972..c474154a42f10a 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -16,7 +16,12 @@ 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 +65,14 @@ class ImgwPibSensorEntityDescription(SensorEntityDescription): 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 e746d66a945126..15a14e4d6f29f2 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/manifest.json b/homeassistant/components/immich/manifest.json index 2a0680e314ae84..ade1a5627eb8f8 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 e37172cb5e14f8..7fab6d5d093f6f 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -124,11 +124,11 @@ async def _async_build_immich( 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 +239,12 @@ async def _async_build_immich( ) 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/indevolt/__init__.py b/homeassistant/components/indevolt/__init__.py index 7a4341d602be4d..20d8d692ad40ae 100644 --- a/homeassistant/components/indevolt/__init__.py +++ b/homeassistant/components/indevolt/__init__.py @@ -4,16 +4,22 @@ 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 +35,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 00000000000000..109a9488f6bbdc --- /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 6abcf50048bee9..320c5ce4f54c6d 100644 --- a/homeassistant/components/indevolt/button.py +++ b/homeassistant/components/indevolt/button.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field 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 @@ -66,5 +68,4 @@ def __init__( 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 3b469282a643c3..5255d4595fa420 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,108 @@ 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, ], 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 19320eec5441f6..7691df082c7599 100644 --- a/homeassistant/components/indevolt/coordinator.py +++ b/homeassistant/components/indevolt/coordinator.py @@ -7,7 +7,13 @@ from typing import Any, Final from aiohttp import ClientError -from indevolt_api import IndevoltAPI, TimeOutException +from indevolt_api import ( + IndevoltAPI, + IndevoltConfig, + IndevoltEnergyMode, + IndevoltRealtimeAction, + TimeOutException, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MODEL @@ -21,11 +27,6 @@ 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,10 +71,10 @@ def __init__(self, hass: HomeAssistant, entry: IndevoltConfigEntry) -> None: 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.""" @@ -108,10 +109,10 @@ async def async_push_data(self, sensor_key: str, value: Any) -> bool: 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 +122,7 @@ async def async_switch_energy_mode( ) # 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 +131,9 @@ async def async_switch_energy_mode( # 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 +149,27 @@ async def async_switch_energy_mode( 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 +178,7 @@ async def async_execute_realtime_action(self, action: list[int]) -> None: ) 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 fadc6e63403ec9..f9d4f8c201c489 100644 --- a/homeassistant/components/indevolt/diagnostics.py +++ b/homeassistant/components/indevolt/diagnostics.py @@ -4,6 +4,8 @@ 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 +17,13 @@ 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 00000000000000..13499365b25943 --- /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 2e67b487bd60dc..2f5159f956e7a3 100644 --- a/homeassistant/components/indevolt/manifest.json +++ b/homeassistant/components/indevolt/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["indevolt-api==1.2.3"] + "requirements": ["indevolt-api==1.6.4"] } diff --git a/homeassistant/components/indevolt/number.py b/homeassistant/components/indevolt/number.py index 0831e9b9657be1..4a69c49708fafb 100644 --- a/homeassistant/components/indevolt/number.py +++ b/homeassistant/components/indevolt/number.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import Final +from indevolt_api import IndevoltConfig + from homeassistant.components.number import ( NumberDeviceClass, NumberEntity, @@ -37,20 +39,19 @@ class IndevoltNumberEntityDescription(NumberEntityDescription): key="discharge_limit", 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], 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, @@ -61,8 +62,8 @@ class IndevoltNumberEntityDescription(NumberEntityDescription): key="inverter_input_limit", 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, @@ -73,8 +74,8 @@ class IndevoltNumberEntityDescription(NumberEntityDescription): key="feedin_power_limit", 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 9e948fd93653ad..713f32f91f63df 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 2850ae2da522ea..4d7343364e1e27 100644 --- a/homeassistant/components/indevolt/select.py +++ b/homeassistant/components/indevolt/select.py @@ -5,6 +5,8 @@ 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,8 +25,8 @@ class IndevoltSelectEntityDescription(SelectEntityDescription): read_key: str write_key: str - value_to_option: dict[int, str] - unavailable_values: list[int] = field(default_factory=list) + value_to_option: dict[IndevoltEnergyMode, str] + unavailable_values: list[IndevoltEnergyMode] = field(default_factory=list) generation: list[int] = field(default_factory=lambda: [1, 2]) @@ -32,14 +34,14 @@ class IndevoltSelectEntityDescription(SelectEntityDescription): 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 75040bf8e7eeec..d99697431810f7 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, @@ -40,7 +48,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): SENSORS: Final = ( # System Operating Information IndevoltSensorEntityDescription( - key="606", + key=IndevoltSystem.OPERATING_MODE, translation_key="mode", state_mapping={"1000": "main", "1001": "sub", "1002": "standalone"}, device_class=SensorDeviceClass.ENUM, @@ -48,7 +56,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="7101", + key=IndevoltConfig.READ_ENERGY_MODE, translation_key="energy_mode", state_mapping={ 0: "outdoor_portable", @@ -59,7 +67,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.ENUM, ), IndevoltSensorEntityDescription( - key="142", + key=IndevoltBattery.RATED_CAPACITY_GEN2, generation=[2], translation_key="rated_capacity", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -67,29 +75,27 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6105", + key=IndevoltConfig.READ_DISCHARGE_LIMIT, generation=[1], - translation_key="rated_capacity", - native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, + 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", + key=IndevoltSystem.BYPASS_POWER, generation=[2], translation_key="bypass_power", native_unit_of_measurement=UnitOfPower.WATT, @@ -98,14 +104,14 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # 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", + key=IndevoltSystem.TOTAL_OUTPUT_ENERGY, generation=[2], translation_key="total_ac_output_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -113,7 +119,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="2105", + key=IndevoltSystem.OFF_GRID_OUTPUT_ENERGY, generation=[2], translation_key="off_grid_output_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -121,7 +127,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="11034", + key=IndevoltSystem.BYPASS_INPUT_ENERGY, generation=[2], translation_key="bypass_input_energy", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -129,7 +135,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6004", + key=IndevoltBattery.DAILY_CHARGING_ENERGY, generation=[2], translation_key="battery_daily_charging_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -137,7 +143,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6005", + key=IndevoltBattery.DAILY_DISCHARGING_ENERGY, generation=[2], translation_key="battery_daily_discharging_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -145,7 +151,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6006", + key=IndevoltBattery.TOTAL_CHARGING_ENERGY, generation=[2], translation_key="battery_total_charging_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -153,7 +159,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="6007", + key=IndevoltBattery.TOTAL_DISCHARGING_ENERGY, generation=[2], translation_key="battery_total_discharging_energy", native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, @@ -162,7 +168,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Electricity Meter Status IndevoltSensorEntityDescription( - key="11016", + key=IndevoltGrid.METER_POWER_GEN2, generation=[2], translation_key="meter_power", native_unit_of_measurement=UnitOfPower.WATT, @@ -170,7 +176,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, ), IndevoltSensorEntityDescription( - key="21028", + key=IndevoltGrid.METER_POWER_GEN1, generation=[1], translation_key="meter_power", native_unit_of_measurement=UnitOfPower.WATT, @@ -179,7 +185,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Grid information IndevoltSensorEntityDescription( - key="2600", + key=IndevoltGrid.VOLTAGE, generation=[2], translation_key="grid_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -188,7 +194,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="2612", + key=IndevoltGrid.FREQUENCY, generation=[2], translation_key="grid_frequency", native_unit_of_measurement=UnitOfFrequency.HERTZ, @@ -198,20 +204,20 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # 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 +225,21 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # 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,7 +247,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, ), IndevoltSensorEntityDescription( - key="1632", + key=IndevoltSolar.DC_INPUT_CURRENT_1, generation=[2], translation_key="dc_input_current_1", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -250,7 +256,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1600", + key=IndevoltSolar.DC_INPUT_VOLTAGE_1, generation=[2], translation_key="dc_input_voltage_1", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -259,7 +265,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): 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,7 +273,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1633", + key=IndevoltSolar.DC_INPUT_CURRENT_2, generation=[2], translation_key="dc_input_current_2", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -276,7 +282,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1601", + key=IndevoltSolar.DC_INPUT_VOLTAGE_2, generation=[2], translation_key="dc_input_voltage_2", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -285,7 +291,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): 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,7 +299,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1634", + key=IndevoltSolar.DC_INPUT_CURRENT_3, generation=[2], translation_key="dc_input_current_3", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -302,7 +308,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1602", + key=IndevoltSolar.DC_INPUT_VOLTAGE_3, generation=[2], translation_key="dc_input_voltage_3", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -311,7 +317,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1666", + key=IndevoltSolar.DC_INPUT_POWER_3, generation=[2], translation_key="dc_input_power_3", native_unit_of_measurement=UnitOfPower.WATT, @@ -320,7 +326,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1635", + key=IndevoltSolar.DC_INPUT_CURRENT_4, generation=[2], translation_key="dc_input_current_4", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -329,7 +335,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1603", + key=IndevoltSolar.DC_INPUT_VOLTAGE_4, generation=[2], translation_key="dc_input_voltage_4", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -338,7 +344,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="1667", + key=IndevoltSolar.DC_INPUT_POWER_4, generation=[2], translation_key="dc_input_power_4", native_unit_of_measurement=UnitOfPower.WATT, @@ -348,42 +354,42 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Battery Pack Serial Numbers IndevoltSensorEntityDescription( - key="9008", + key=IndevoltBattery.MAIN_SERIAL_NUMBER, generation=[2], translation_key="main_serial_number", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9032", + 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", + 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", + 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", + 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", + key=IndevoltBattery.PACK_5_SERIAL_NUMBER, generation=[2], translation_key="battery_pack_5_serial_number", entity_category=EntityCategory.DIAGNOSTIC, @@ -391,7 +397,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Battery Pack SOC IndevoltSensorEntityDescription( - key="9000", + key=IndevoltBattery.MAIN_SOC, generation=[2], translation_key="main_soc", native_unit_of_measurement=PERCENTAGE, @@ -401,7 +407,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9016", + key=IndevoltBattery.PACK_1_SOC, generation=[2], translation_key="battery_pack_1_soc", native_unit_of_measurement=PERCENTAGE, @@ -411,7 +417,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9035", + key=IndevoltBattery.PACK_2_SOC, generation=[2], translation_key="battery_pack_2_soc", native_unit_of_measurement=PERCENTAGE, @@ -421,7 +427,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9054", + key=IndevoltBattery.PACK_3_SOC, generation=[2], translation_key="battery_pack_3_soc", native_unit_of_measurement=PERCENTAGE, @@ -431,7 +437,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9149", + key=IndevoltBattery.PACK_4_SOC, generation=[2], translation_key="battery_pack_4_soc", native_unit_of_measurement=PERCENTAGE, @@ -441,7 +447,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9202", + key=IndevoltBattery.PACK_5_SOC, generation=[2], translation_key="battery_pack_5_soc", native_unit_of_measurement=PERCENTAGE, @@ -452,7 +458,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Battery Pack Temperature IndevoltSensorEntityDescription( - key="9012", + key=IndevoltBattery.MAIN_TEMPERATURE, generation=[2], translation_key="main_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -462,7 +468,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9030", + key=IndevoltBattery.PACK_1_TEMPERATURE, generation=[2], translation_key="battery_pack_1_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -472,7 +478,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9049", + key=IndevoltBattery.PACK_2_TEMPERATURE, generation=[2], translation_key="battery_pack_2_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -482,7 +488,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9068", + key=IndevoltBattery.PACK_3_TEMPERATURE, generation=[2], translation_key="battery_pack_3_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -492,7 +498,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9163", + key=IndevoltBattery.PACK_4_TEMPERATURE, generation=[2], translation_key="battery_pack_4_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -502,7 +508,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9216", + key=IndevoltBattery.PACK_5_TEMPERATURE, generation=[2], translation_key="battery_pack_5_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -513,7 +519,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Battery Pack Voltage IndevoltSensorEntityDescription( - key="9004", + key=IndevoltBattery.MAIN_VOLTAGE, generation=[2], translation_key="main_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -523,7 +529,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9020", + key=IndevoltBattery.PACK_1_VOLTAGE, generation=[2], translation_key="battery_pack_1_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -533,7 +539,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9039", + key=IndevoltBattery.PACK_2_VOLTAGE, generation=[2], translation_key="battery_pack_2_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -543,7 +549,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9058", + key=IndevoltBattery.PACK_3_VOLTAGE, generation=[2], translation_key="battery_pack_3_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -553,7 +559,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9153", + key=IndevoltBattery.PACK_4_VOLTAGE, generation=[2], translation_key="battery_pack_4_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -563,7 +569,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="9206", + key=IndevoltBattery.PACK_5_VOLTAGE, generation=[2], translation_key="battery_pack_5_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, @@ -574,7 +580,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): ), # Battery Pack Current IndevoltSensorEntityDescription( - key="9013", + key=IndevoltBattery.MAIN_CURRENT, generation=[2], translation_key="main_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -584,7 +590,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19173", + key=IndevoltBattery.PACK_1_CURRENT, generation=[2], translation_key="battery_pack_1_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -594,7 +600,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19174", + key=IndevoltBattery.PACK_2_CURRENT, generation=[2], translation_key="battery_pack_2_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -604,7 +610,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19175", + key=IndevoltBattery.PACK_3_CURRENT, generation=[2], translation_key="battery_pack_3_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -614,7 +620,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19176", + key=IndevoltBattery.PACK_4_CURRENT, generation=[2], translation_key="battery_pack_4_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -624,7 +630,7 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): entity_registry_enabled_default=False, ), IndevoltSensorEntityDescription( - key="19177", + key=IndevoltBattery.PACK_5_CURRENT, generation=[2], translation_key="battery_pack_5_current", native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, @@ -637,11 +643,41 @@ class IndevoltSensorEntityDescription(SensorEntityDescription): # 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, + ), # Battery Pack 1 + ( + IndevoltBattery.PACK_2_SERIAL_NUMBER, + IndevoltBattery.PACK_2_SOC, + IndevoltBattery.PACK_2_TEMPERATURE, + IndevoltBattery.PACK_2_VOLTAGE, + IndevoltBattery.PACK_2_CURRENT, + ), # Battery Pack 2 + ( + IndevoltBattery.PACK_3_SERIAL_NUMBER, + IndevoltBattery.PACK_3_SOC, + IndevoltBattery.PACK_3_TEMPERATURE, + IndevoltBattery.PACK_3_VOLTAGE, + IndevoltBattery.PACK_3_CURRENT, + ), # Battery Pack 3 + ( + IndevoltBattery.PACK_4_SERIAL_NUMBER, + IndevoltBattery.PACK_4_SOC, + IndevoltBattery.PACK_4_TEMPERATURE, + IndevoltBattery.PACK_4_VOLTAGE, + IndevoltBattery.PACK_4_CURRENT, + ), # Battery Pack 4 + ( + IndevoltBattery.PACK_5_SERIAL_NUMBER, + IndevoltBattery.PACK_5_SOC, + IndevoltBattery.PACK_5_TEMPERATURE, + IndevoltBattery.PACK_5_VOLTAGE, + IndevoltBattery.PACK_5_CURRENT, + ), # Battery Pack 5 ] diff --git a/homeassistant/components/indevolt/services.py b/homeassistant/components/indevolt/services.py new file mode 100644 index 00000000000000..25b37951b90dee --- /dev/null +++ b/homeassistant/components/indevolt/services.py @@ -0,0 +1,218 @@ +"""Services for Indevolt integration.""" + +from __future__ import annotations + +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 00000000000000..786cdbfbc2e10c --- /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 8b127e3cce677c..b64c1b90478896 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": { @@ -307,6 +336,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 c5bab6053ad963..a908b5d9782d27 100644 --- a/homeassistant/components/indevolt/switch.py +++ b/homeassistant/components/indevolt/switch.py @@ -5,6 +5,8 @@ from dataclasses import dataclass, field from typing import Any, Final +from indevolt_api import IndevoltConfig + from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, @@ -37,8 +39,8 @@ class IndevoltSwitchEntityDescription(SwitchEntityDescription): key="grid_charging", translation_key="grid_charging", generation=[2], - read_key="2618", - write_key="1143", + read_key=IndevoltConfig.READ_GRID_CHARGING, + write_key=IndevoltConfig.WRITE_GRID_CHARGING, read_on_value=1001, read_off_value=1000, device_class=SwitchDeviceClass.SWITCH, @@ -47,16 +49,16 @@ class IndevoltSwitchEntityDescription(SwitchEntityDescription): key="light", translation_key="light", generation=[2], - read_key="7171", - write_key="7265", + 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", + read_key=IndevoltConfig.READ_BYPASS, + write_key=IndevoltConfig.WRITE_BYPASS, device_class=SwitchDeviceClass.SWITCH, ), ) diff --git a/homeassistant/components/infrared/manifest.json b/homeassistant/components/infrared/manifest.json index d81f5ecffa7f52..0fa1428d7b16cb 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.0.0"] } diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index 5fd500848958ab..49ca197aec6b9f 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -26,7 +26,6 @@ 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 +80,6 @@ async def _update_data(self, item: dict, update_data: dict) -> dict: 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/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 1a1306c2a2f0d4..de0177a4f97015 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -6,7 +6,7 @@ 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 @@ ) _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 9e5287b041d67a..aabeb2a3d5fecb 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/manifest.json b/homeassistant/components/insteon/manifest.json index b1398326de46f3..1face9fdfbbf99 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/services.py b/homeassistant/components/insteon/services.py index eb671a720ad0a2..7bcdabbc06865e 100644 --- a/homeassistant/components/insteon/services.py +++ b/homeassistant/components/insteon/services.py @@ -35,6 +35,7 @@ async_dispatcher_send, dispatcher_send, ) +from homeassistant.helpers.service import async_register_admin_service from .const import ( CONF_CAT, @@ -231,11 +232,19 @@ async def async_remove_insteon_device( ) 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 +278,8 @@ async def async_remove_insteon_device( 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 5f48306754edb4..229ed007d0e594 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -11,7 +11,6 @@ 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 +171,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/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 8d58a0dd45b58f..435eee01937827 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -78,8 +78,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/ios/__init__.py b/homeassistant/components/ios/__init__.py index ef141a28475e63..af7da2be4eec8f 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/ipma/system_health.py b/homeassistant/components/ipma/system_health.py index 7b6a5c517c7dd4..44c0346e89840f 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/israel_rail/coordinator.py b/homeassistant/components/israel_rail/coordinator.py index 190ed938790a37..4ed29908af5660 100644 --- a/homeassistant/components/israel_rail/coordinator.py +++ b/homeassistant/components/israel_rail/coordinator.py @@ -25,6 +25,7 @@ class DataConnection: """A connection data class.""" departure: datetime | None + departure_delay: int | None platform: str start: str destination: str @@ -83,6 +84,7 @@ async def _async_update_data(self) -> 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 0362f7d2224cb5..ad9f3c1a17f9b5 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 6e3324de7ae850..f4ea5f589ed038 100644 --- a/homeassistant/components/israel_rail/sensor.py +++ b/homeassistant/components/israel_rail/sensor.py @@ -12,7 +12,9 @@ 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 +69,15 @@ class IsraelRailSensorEntityDescription(SensorEntityDescription): 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 3b16015fe3495d..e7380c80245755 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/isy994/const.py b/homeassistant/components/isy994/const.py index b43385a0e5de7e..9a0acf73601857 100644 --- a/homeassistant/components/isy994/const.py +++ b/homeassistant/components/isy994/const.py @@ -16,6 +16,7 @@ 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 @@ "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 @@ "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_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/sensor.py b/homeassistant/components/isy994/sensor.py index 6e0b5a89637953..6a0b19d3284f96 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -29,13 +29,19 @@ 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 +79,7 @@ "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 +102,56 @@ "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 +159,21 @@ } +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 +210,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 +329,24 @@ def __init__( 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/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index 2be3090410e19a..f8bd7667b7360f 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -168,7 +168,6 @@ def _update_from_session_data(self) -> None: 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/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index 1ab967ecfa458e..0cbb0df787e800 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/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py index cbde80b65bc902..52d45acd33b726 100644 --- a/homeassistant/components/jvc_projector/coordinator.py +++ b/homeassistant/components/jvc_projector/coordinator.py @@ -7,7 +7,12 @@ 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 +149,16 @@ async def _update_command_state( 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/manifest.json b/homeassistant/components/jvc_projector/manifest.json index 389b9ff2b55a97..d2913b5dd902bc 100644 --- a/homeassistant/components/jvc_projector/manifest.json +++ b/homeassistant/components/jvc_projector/manifest.json @@ -1,11 +1,11 @@ { "domain": "jvc_projector", "name": "JVC Projector", - "codeowners": ["@SteveEasley", "@msavazzi"], + "codeowners": ["@SteveEasley"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/jvc_projector", "integration_type": "device", "iot_class": "local_polling", "loggers": ["jvcprojector"], - "requirements": ["pyjvcprojector==2.0.5"] + "requirements": ["pyjvcprojector==2.0.6"] } diff --git a/homeassistant/components/kaleidescape/manifest.json b/homeassistant/components/kaleidescape/manifest.json index 7ad51d60c56f1c..699cbe8dc0d3bf 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.3"], + "requirements": ["pykaleidescape==1.1.5"], "ssdp": [ { "deviceType": "schemas-upnp-org:device:Basic:1", diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 358f9600845a24..007481de42833a 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -4,7 +4,7 @@ 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 +17,6 @@ DEFAULT_CONSIDER_HOME, DEFAULT_INTERFACE, DEFAULT_SCAN_INTERVAL, - DOMAIN, ) from .router import KeeneticConfigEntry, KeeneticRouter @@ -27,7 +26,6 @@ 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 +83,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 +92,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 cec4796176ebd2..304c7639187cc2 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -198,6 +198,8 @@ async def async_step_user( 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/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json index 2159dd9d90eab6..76197d32fe572b 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/kiosker/__init__.py b/homeassistant/components/kiosker/__init__.py new file mode 100644 index 00000000000000..21ba2bc5f2ef33 --- /dev/null +++ b/homeassistant/components/kiosker/__init__.py @@ -0,0 +1,29 @@ +"""The Kiosker integration.""" + +from __future__ import annotations + +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 00000000000000..1d03a140e505c9 --- /dev/null +++ b/homeassistant/components/kiosker/binary_sensor.py @@ -0,0 +1,73 @@ +"""Support for Kiosker binary sensors.""" + +from __future__ import annotations + +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 00000000000000..b4ab16750a418b --- /dev/null +++ b/homeassistant/components/kiosker/config_flow.py @@ -0,0 +1,200 @@ +"""Config flow for the Kiosker integration.""" + +from __future__ import annotations + +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 00000000000000..40cc8b9d03310d --- /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 00000000000000..49713cff45ad39 --- /dev/null +++ b/homeassistant/components/kiosker/coordinator.py @@ -0,0 +1,105 @@ +"""DataUpdateCoordinator for Kiosker.""" + +from __future__ import annotations + +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 00000000000000..8abf4fc8c61fdb --- /dev/null +++ b/homeassistant/components/kiosker/entity.py @@ -0,0 +1,53 @@ +"""Base entity for Kiosker.""" + +from __future__ import annotations + +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 00000000000000..749a8d7d02e596 --- /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 00000000000000..fc8c2ed911faa9 --- /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 00000000000000..36e0f730ed92c6 --- /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 00000000000000..76457b0c088fb8 --- /dev/null +++ b/homeassistant/components/kiosker/sensor.py @@ -0,0 +1,86 @@ +"""Sensor platform for Kiosker.""" + +from __future__ import annotations + +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 00000000000000..5cd897381e76f8 --- /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/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 6bf5896dd70300..52d79e37f43ba2 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -62,6 +62,7 @@ Platform.LAWN_MOWER, Platform.LOCK, Platform.NOTIFY, + Platform.RADIO_FREQUENCY, Platform.SENSOR, Platform.SWITCH, Platform.WEATHER, diff --git a/homeassistant/components/kitchen_sink/infrared.py b/homeassistant/components/kitchen_sink/infrared.py index 4f93c9be0c59ee..437a993559a8f0 100644 --- a/homeassistant/components/kitchen_sink/infrared.py +++ b/homeassistant/components/kitchen_sink/infrared.py @@ -55,11 +55,6 @@ def __init__( 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/radio_frequency.py b/homeassistant/components/kitchen_sink/radio_frequency.py new file mode 100644 index 00000000000000..c11983ffe5a92d --- /dev/null +++ b/homeassistant/components/kitchen_sink/radio_frequency.py @@ -0,0 +1,67 @@ +"""Demo platform that offers a fake radio frequency entity.""" + +from __future__ import annotations + +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/strings.json b/homeassistant/components/kitchen_sink/strings.json index 15305d711b26a1..e369e0942bdc77 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/knx/__init__.py b/homeassistant/components/knx/__init__.py index 40c5ea8a65b8af..d9ec7dd0937bc6 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -123,6 +123,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 +160,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/entity.py b/homeassistant/components/knx/entity.py index 593e91c99c6abb..17013abbdcffa7 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from xknx.devices import Device as XknxDevice @@ -20,6 +21,15 @@ 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 +67,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 +87,16 @@ async def async_added_to_hass(self) -> None: 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 +105,10 @@ async def async_will_remove_from_hass(self) -> None: """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/knx_module.py b/homeassistant/components/knx/knx_module.py index 105817a04d5922..af2c1657de4df3 100644 --- a/homeassistant/components/knx/knx_module.py +++ b/homeassistant/components/knx/knx_module.py @@ -54,10 +54,12 @@ 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 +78,7 @@ def __init__( 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 +114,7 @@ def __init__( 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 +238,7 @@ def connection_config(self) -> ConnectionConfig: 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 +249,7 @@ def add_to_group_address_entities( 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/manifest.json b/homeassistant/components/knx/manifest.json index c0a838b48c0c5d..2fb9d53ee0d417 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/storage/config_store.py b/homeassistant/components/knx/storage/config_store.py index 05a74fcc15d8d7..7a51b01d9c9f61 100644 --- a/homeassistant/components/knx/storage/config_store.py +++ b/homeassistant/components/knx/storage/config_store.py @@ -11,15 +11,16 @@ 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 @@ async def _async_migrate_func( # 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 @@ def __init__( ) self.data = KNXConfigStoreModel( # initialize with default structure entities={}, + expose={}, time_server={}, ) self._platform_controllers: dict[Platform, PlatformControllerBase] = {} @@ -99,6 +106,10 @@ async def load_data(self) -> None: "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 @@ def get_entity_entries(self) -> list[er.RegistryEntry]: 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 @@ def get_time_server_config(self) -> KNXTimeServerStoreModel: 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 00000000000000..524ceabaab09c8 --- /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 de158f4c5f9c3c..e4c33e319d1630 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/strings.json b/homeassistant/components/knx/strings.json index 04372c78fdad20..3abb766c958e62 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/websocket.py b/homeassistant/components/knx/websocket.py index e70f89d59340b6..c48aab486c07d0 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -14,6 +14,7 @@ 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 +36,7 @@ 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 +65,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 +518,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 +595,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 b5c8aed7d3258f..02083bb832f630 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/const.py b/homeassistant/components/kodi/const.py index 1ac439b27c3ae0..a6d78410c6d4d8 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/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 42cd39d1473f20..8d3a2651a82bce 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 d6bdab37a9c80b..2af8be9da9cb2f 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/panel.py b/homeassistant/components/konnected/panel.py index e2dfc6be06ad84..702f814a49ad78 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 155e99a70029af..af2232581616dd 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -46,6 +46,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 54f74f0d46106a..bbfad9ffd9e388 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/kraken/__init__.py b/homeassistant/components/kraken/__init__.py index 065b647a971c48..7f4d080c40371f 100644 --- a/homeassistant/components/kraken/__init__.py +++ b/homeassistant/components/kraken/__init__.py @@ -2,39 +2,38 @@ 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 54a817f0a50dc1..e8ac9a6f894e42 100644 --- a/homeassistant/components/kraken/config_flow.py +++ b/homeassistant/components/kraken/config_flow.py @@ -8,17 +8,13 @@ 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 +26,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 +75,8 @@ async def async_step_init( ) 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/coordinator.py b/homeassistant/components/kraken/coordinator.py index c222e58ba15ddb..b4a3f6f651e033 100644 --- a/homeassistant/components/kraken/coordinator.py +++ b/homeassistant/components/kraken/coordinator.py @@ -28,10 +28,13 @@ _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 f301a54ee07cea..26d60987f00c73 100644 --- a/homeassistant/components/kraken/sensor.py +++ b/homeassistant/components/kraken/sensor.py @@ -11,7 +11,6 @@ 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 +27,7 @@ DOMAIN, KrakenResponse, ) -from .coordinator import KrakenData +from .coordinator import KrakenConfigEntry, KrakenData _LOGGER = logging.getLogger(__name__) @@ -138,7 +137,7 @@ class KrakenSensorEntityDescription(SensorEntityDescription): 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 +148,7 @@ def _async_add_kraken_sensors(tracked_asset_pairs: list[str]) -> None: entities.extend( [ KrakenSensor( - hass.data[DOMAIN], + config_entry.runtime_data, tracked_asset_pair, description, ) @@ -161,7 +160,9 @@ def _async_add_kraken_sensors(tracked_asset_pairs: list[str]) -> None: _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/landisgyr_heat_meter/config_flow.py b/homeassistant/components/landisgyr_heat_meter/config_flow.py index f7288b8a0cd586..3f573f7f1d0190 100644 --- a/homeassistant/components/landisgyr_heat_meter/config_flow.py +++ b/homeassistant/components/landisgyr_heat_meter/config_flow.py @@ -7,7 +7,6 @@ from typing import Any import serial -from serial.tools import list_ports import ultraheat_api import voluptuous as vol @@ -45,9 +44,7 @@ async def async_step_user( 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 +115,19 @@ async def validate_ultraheat(self, port: str) -> tuple[str, str]: 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/lannouncer/notify.py b/homeassistant/components/lannouncer/notify.py index 4b5f249a2f17ad..c24148ab6993bd 100644 --- a/homeassistant/components/lannouncer/notify.py +++ b/homeassistant/components/lannouncer/notify.py @@ -2,111 +2,29 @@ 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, - } -) +PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -_LOGGER = logging.getLogger(__name__) - -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 63b2e86aa829b4..1152be3fde9eab 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/launch_library/__init__.py b/homeassistant/components/launch_library/__init__.py index 9b29af194e7db9..70095628d14e36 100644 --- a/homeassistant/components/launch_library/__init__.py +++ b/homeassistant/components/launch_library/__init__.py @@ -2,31 +2,31 @@ 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/coordinator.py b/homeassistant/components/launch_library/coordinator.py index b88bc105630ddf..5f06c49e36f439 100644 --- a/homeassistant/components/launch_library/coordinator.py +++ b/homeassistant/components/launch_library/coordinator.py @@ -16,6 +16,9 @@ from .const import DOMAIN +type LaunchLibraryConfigEntry = ConfigEntry[LaunchLibraryCoordinator] + + _LOGGER = logging.getLogger(__name__) @@ -29,12 +32,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 d96d5fed7f54fc..072752cb230782 100644 --- a/homeassistant/components/launch_library/diagnostics.py +++ b/homeassistant/components/launch_library/diagnostics.py @@ -6,20 +6,18 @@ 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 e844744c83463f..5b73f58187f23f 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -14,7 +14,6 @@ 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 +22,7 @@ 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 +117,12 @@ class LaunchLibrarySensorEntityDescription(SensorEntityDescription): 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/lawn_mower/conditions.yaml b/homeassistant/components/lawn_mower/conditions.yaml index e9f29941bc27d6..5fb1de71345ff9 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 973d046979aff0..da56ee71cc8669 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_docked": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is docked" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is encountering an error" @@ -27,6 +35,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is mowing" @@ -36,6 +47,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is paused" @@ -45,6 +59,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::condition_for_name%]" } }, "name": "Lawn mower is returning" @@ -62,21 +79,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "dock": { "description": "Returns a lawn mower to its dock.", @@ -98,6 +100,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" } }, "name": "Lawn mower returned to dock" @@ -107,6 +112,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" } }, "name": "Lawn mower encountered an error" @@ -116,6 +124,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" } }, "name": "Lawn mower paused mowing" @@ -125,6 +136,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" } }, "name": "Lawn mower started mowing" @@ -134,6 +148,9 @@ "fields": { "behavior": { "name": "[%key:component::lawn_mower::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lawn_mower::common::trigger_for_name%]" } }, "name": "Lawn mower started returning to dock" diff --git a/homeassistant/components/lawn_mower/triggers.yaml b/homeassistant/components/lawn_mower/triggers.yaml index bc3cb321cf8e76..296919a4ab3d99 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/websocket.py b/homeassistant/components/lcn/websocket.py index 76c800cd5ea8d3..421e47c5cb0dc0 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -11,6 +11,7 @@ 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 +77,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/lg_infrared/media_player.py b/homeassistant/components/lg_infrared/media_player.py index 4985a0394b8868..9331d9ba25f00e 100644 --- a/homeassistant/components/lg_infrared/media_player.py +++ b/homeassistant/components/lg_infrared/media_player.py @@ -57,11 +57,11 @@ def __init__(self, entry: ConfigEntry, infrared_entity_id: str) -> None: 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 c2509889760134..d97464d9a9c10c 100644 --- a/homeassistant/components/lg_netcast/__init__.py +++ b/homeassistant/components/lg_netcast/__init__.py @@ -11,7 +11,7 @@ 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/remote.py b/homeassistant/components/lg_netcast/remote.py new file mode 100644 index 00000000000000..db5562a598e82b --- /dev/null +++ b/homeassistant/components/lg_netcast/remote.py @@ -0,0 +1,83 @@ +"""Remote control support for LG Netcast TV.""" + +from __future__ import annotations + +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_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index ffe9c07e5415f4..631b61dbd7982a 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.9"] + "requirements": ["thinqconnect==1.0.12"] } diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py index 81b2c570eaba1f..313804677b579a 100644 --- a/homeassistant/components/lidarr/sensor.py +++ b/homeassistant/components/lidarr/sensor.py @@ -97,7 +97,8 @@ class LidarrSensorEntityDescription( 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/config_flow.py b/homeassistant/components/liebherr/config_flow.py index 8aa1f5628934d0..5f0686f1113cf7 100644 --- a/homeassistant/components/liebherr/config_flow.py +++ b/homeassistant/components/liebherr/config_flow.py @@ -6,7 +6,7 @@ import logging from typing import Any -from pyliebherrhomeapi import LiebherrClient +from pyliebherrhomeapi import Device, LiebherrClient from pyliebherrhomeapi.exceptions import ( LiebherrAuthenticationError, LiebherrConnectionError, @@ -31,10 +31,12 @@ 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/light.py b/homeassistant/components/liebherr/light.py index f952e04c7aaf0e..9665bf4822f139 100644 --- a/homeassistant/components/liebherr/light.py +++ b/homeassistant/components/liebherr/light.py @@ -6,7 +6,10 @@ 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 +20,6 @@ from .coordinator import LiebherrConfigEntry, LiebherrCoordinator from .entity import LiebherrEntity -DEFAULT_MAX_BRIGHTNESS_LEVEL = 5 - PARALLEL_UPDATES = 1 @@ -108,7 +109,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: 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 9130562f3d8c58..97ae7558bd5fb7 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/quality_scale.yaml b/homeassistant/components/liebherr/quality_scale.yaml index 712bedd1c2a109..5639ae68962fe1 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/lifx/light.py b/homeassistant/components/lifx/light.py index 69f7580a054396..22f5555cf80bc9 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -232,10 +232,7 @@ async def set_state(self, **kwargs: Any) -> None: ) 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 +309,40 @@ async def set_color( 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 +427,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 +448,7 @@ async def set_color( 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 +463,9 @@ async def set_color( 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 +478,7 @@ async def set_color( 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 +504,21 @@ async def update_color_zones( 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 +531,7 @@ async def set_color( # 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/light/__init__.py b/homeassistant/components/light/__init__.py index de1f9841a50785..b91c3d486bd442 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -26,7 +26,6 @@ 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 +222,6 @@ def get_supported_color_modes(hass: HomeAssistant, entity_id: str) -> set[str] | _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 229707d6c89913..fdcb5f3650b46b 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/strings.json b/homeassistant/components/light/strings.json index 69356bb4ad824b..1c438e5f7c167f 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -1,6 +1,7 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "field_brightness_description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness.", "field_brightness_name": "Brightness value", @@ -36,6 +37,7 @@ "field_xy_color_name": "XY-color", "section_advanced_fields_name": "Advanced options", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -45,6 +47,9 @@ "behavior": { "name": "[%key:component::light::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::light::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::light::common::condition_threshold_name%]" } @@ -56,6 +61,9 @@ "fields": { "behavior": { "name": "[%key:component::light::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::light::common::condition_for_name%]" } }, "name": "Light is off" @@ -65,6 +73,9 @@ "fields": { "behavior": { "name": "[%key:component::light::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::light::common::condition_for_name%]" } }, "name": "Light is on" @@ -309,12 +320,6 @@ "yellowgreen": "Yellow green" } }, - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, "flash": { "options": { "long": "Long", @@ -326,13 +331,6 @@ "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } } }, "services": { @@ -515,6 +513,9 @@ "behavior": { "name": "[%key:component::light::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::light::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::light::common::trigger_threshold_name%]" } @@ -526,6 +527,9 @@ "fields": { "behavior": { "name": "[%key:component::light::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::light::common::trigger_for_name%]" } }, "name": "Light turned off" @@ -535,6 +539,9 @@ "fields": { "behavior": { "name": "[%key:component::light::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::light::common::trigger_for_name%]" } }, "name": "Light turned on" diff --git a/homeassistant/components/light/triggers.yaml b/homeassistant/components/light/triggers.yaml index eed93d0d53613f..4231f672a23bde 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/linkplay/__init__.py b/homeassistant/components/linkplay/__init__.py index 98481feb9ffefd..3c94abd09e5238 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/media_player.py b/homeassistant/components/linkplay/media_player.py index 702aa0c7629688..e7c5e687873849 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -1,4 +1,5 @@ """Support for LinkPlay media players.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py index 63d04a3afc4d69..0c55ddc50bfc9d 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 489596c7ec6951..a4bfa1c511b075 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/litejet/__init__.py b/homeassistant/components/litejet/__init__.py index 84667d6c94dc9f..0c30aa4b73e219 100644 --- a/homeassistant/components/litejet/__init__.py +++ b/homeassistant/components/litejet/__init__.py @@ -9,12 +9,14 @@ 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 handle_stop(event: Event) -> None: 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 aeae8f52144684..03182b79ef097f 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -8,16 +8,12 @@ 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 +73,7 @@ async def async_step_user( @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 7a10f4d6754f0f..e010d1ea13ff86 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 95870927072700..54ded894f4d4ea 100644 --- a/homeassistant/components/litejet/light.py +++ b/homeassistant/components/litejet/light.py @@ -13,12 +13,12 @@ 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 +26,12 @@ 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 +52,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 dd96b5accb6356..657c882e74dc81 100644 --- a/homeassistant/components/litejet/scene.py +++ b/homeassistant/components/litejet/scene.py @@ -6,12 +6,12 @@ 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 @@ 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 1b46ba360c3c9a..e1468347e477c9 100644 --- a/homeassistant/components/litejet/switch.py +++ b/homeassistant/components/litejet/switch.py @@ -5,12 +5,12 @@ 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 @@ 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 a35bf6fb65ed3b..8f10ab619ad2aa 100644 --- a/homeassistant/components/litejet/trigger.py +++ b/homeassistant/components/litejet/trigger.py @@ -6,7 +6,6 @@ from datetime import datetime from typing import cast -from pylitejet import LiteJet import voluptuous as vol from homeassistant.const import CONF_PLATFORM @@ -109,7 +108,7 @@ def released() -> None: ): 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/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 4dc64b08feca60..dc7bcd5acc8bf8 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -6,7 +6,7 @@ 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 +32,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 +61,14 @@ class 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/manifest.json b/homeassistant/components/litterrobot/manifest.json index 8518d9781d1c56..04440098585246 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.3.2"] + "requirements": ["pylitterbot==2025.4.0"] } diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 3b6d6070f5a294..0b91a8028930c6 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -197,6 +197,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_file/strings.json b/homeassistant/components/local_file/strings.json index 14866fa63006eb..d35b4e653c1b32 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/locative/__init__.py b/homeassistant/components/locative/__init__.py index 4154f343f4272e..721cc0b9209884 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -113,6 +113,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 9663efdd76e166..16e207426f2c3e 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/conditions.yaml b/homeassistant/components/lock/conditions.yaml index 4bc0ef437a348c..8952c15faa5c3f 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/strings.json b/homeassistant/components/lock/strings.json index b53a2f92cf3f64..87d2928077e75c 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_jammed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::condition_for_name%]" } }, "name": "Lock is jammed" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::condition_for_name%]" } }, "name": "Lock is locked" @@ -27,6 +35,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::condition_for_name%]" } }, "name": "Lock is open" @@ -36,6 +47,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::condition_for_name%]" } }, "name": "Lock is unlocked" @@ -92,21 +106,6 @@ "message": "The code for {entity_id} doesn't match pattern {code_format}." } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "lock": { "description": "Locks a lock.", @@ -146,6 +145,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::trigger_for_name%]" } }, "name": "Lock jammed" @@ -155,6 +157,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::trigger_for_name%]" } }, "name": "Lock locked" @@ -164,6 +169,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::trigger_for_name%]" } }, "name": "Lock opened" @@ -173,6 +181,9 @@ "fields": { "behavior": { "name": "[%key:component::lock::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::lock::common::trigger_for_name%]" } }, "name": "Lock unlocked" diff --git a/homeassistant/components/lock/triggers.yaml b/homeassistant/components/lock/triggers.yaml index 72b0fc5f476c1a..d60b78c2c8784f 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 de2ff570f0c058..d73d64bb818acb 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -30,7 +30,6 @@ 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 +61,6 @@ ) -@bind_hass def log_entry( hass: HomeAssistant, name: str, @@ -76,7 +74,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 282580bdc95ce9..abbd5c8f050dc9 100644 --- a/homeassistant/components/logbook/const.py +++ b/homeassistant/components/logbook/const.py @@ -11,9 +11,9 @@ # 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 238e6a0dda8a27..a8a07b26b6fee9 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -2,7 +2,7 @@ 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 +11,9 @@ 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 +75,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 +93,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 +149,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 +171,12 @@ def _forward_events_filtered_by_entities_filter(event: Event) -> None: @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 +187,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 f27a470a23dae7..d8e4d6f6815627 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -162,7 +162,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 1a139bb379e8e8..366127342f9540 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -2,15 +2,17 @@ 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 +39,7 @@ 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 +83,18 @@ 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 +110,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 +126,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 +153,7 @@ def __init__( 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 +201,67 @@ def get_events( 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 +270,7 @@ def humanify( self.ent_reg, self.logbook_run, self.context_augmenter, + query_parent_user_ids, ) ) @@ -205,6 +281,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 +297,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 +369,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 +398,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/common.py b/homeassistant/components/logbook/queries/common.py index 8f9ab8a80cd08a..cc67786e58f5ee 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -2,12 +2,14 @@ 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 +124,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/strings.json b/homeassistant/components/logbook/strings.json index d56cc2cfd69acb..e5d2ec3a822442 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 4b767f66d699b0..2d5119c7ae6169 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -14,6 +14,7 @@ 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 +290,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 +300,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 +361,15 @@ def _queue_or_cancel(event: Event) -> None: 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 8593b3c478e4e2..8f7575850f5d55 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -10,6 +10,7 @@ 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 +87,16 @@ def async_service_handler(service: ServiceCall) -> None: 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/websocket_api.py b/homeassistant/components/logger/websocket_api.py index 660bdf4c599a5f..d20dc5cd680a16 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/london_underground/const.py b/homeassistant/components/london_underground/const.py index 9c96ff1ece04bb..8d6425bc7a8bde 100644 --- a/homeassistant/components/london_underground/const.py +++ b/homeassistant/components/london_underground/const.py @@ -29,6 +29,8 @@ "Suffragette", "Weaver", "Windrush", + "Tram", + "IFS Cloud Cable Car", ] # Default lines to monitor if none selected diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json index 15cf41ef98cd8e..d05376b863a35e 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/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 1513d1a68699ed..dae507ca768c54 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/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 0eea15cf2e283d..28d836b80d130b 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -12,7 +12,7 @@ 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 +286,7 @@ async def _process_create_data(self, data: dict) -> dict: 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 96f84ccbc6056c..b2f1c80dda20ef 100644 --- a/homeassistant/components/lovelace/resources.py +++ b/homeassistant/components/lovelace/resources.py @@ -62,14 +62,32 @@ def __init__(self, hass: HomeAssistant, ll_config: LovelaceConfig) -> None: ) 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 +136,6 @@ def _get_suggested_id(self, info: dict) -> str: 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/luftdaten/coordinator.py b/homeassistant/components/luftdaten/coordinator.py index 2c311bb6409080..3a608dcc9ce015 100644 --- a/homeassistant/components/luftdaten/coordinator.py +++ b/homeassistant/components/luftdaten/coordinator.py @@ -9,7 +9,7 @@ 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 +47,22 @@ async def _async_update_data(self) -> 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/sensor.py b/homeassistant/components/luftdaten/sensor.py index 07500f2e10c0c1..6481d1709d855c 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -27,6 +27,8 @@ 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 412d665e0dd1c8..d40f74e32e707b 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 2e280168a86acd..3c1ae4b2f82e17 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 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 @@ 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 b5004ffdce4af7..fa9951d2ae7e94 100644 --- a/homeassistant/components/lunatone/config_flow.py +++ b/homeassistant/components/lunatone/config_flow.py @@ -5,15 +5,17 @@ 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 @@ async def async_step_user( 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 + ) + + 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="user", - data_schema=DATA_SCHEMA, - errors=errors, + 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/light.py b/homeassistant/components/lunatone/light.py index a733fd6588b0aa..bfba1f303fadd4 100644 --- a/homeassistant/components/lunatone/light.py +++ b/homeassistant/components/lunatone/light.py @@ -2,7 +2,6 @@ from __future__ import annotations -import asyncio from typing import Any from lunatone_rest_api_client import DALIBroadcast @@ -10,6 +9,9 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_RGB_COLOR, + ATTR_RGBW_COLOR, ColorMode, LightEntity, brightness_supported, @@ -28,7 +30,6 @@ ) PARALLEL_UPDATES = 0 -STATUS_UPDATE_DELAY = 0.04 async def async_setup_entry( @@ -41,17 +42,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 +75,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 +100,7 @@ def device_info(self) -> DeviceInfo: 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 +126,13 @@ def brightness(self) -> int | None: @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 +141,32 @@ def supported_color_modes(self) -> set[ColorMode]: """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 +176,26 @@ def _handle_coordinator_update(self) -> None: 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 +206,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: 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 +225,7 @@ def __init__( 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 +234,7 @@ def __init__( 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 +249,7 @@ def __init__( 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 +264,9 @@ async def async_turn_on(self, **kwargs: Any) -> None: 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 33ca0382fbb23e..8f6ee96b7279f8 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 1ba52be8e54a3d..76006f73eefb94 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/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index 86c84ae23b5c0d..ddecffb1a8f479 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -29,6 +29,7 @@ Platform.FAN, Platform.LIGHT, Platform.SCENE, + Platform.SELECT, Platform.SWITCH, ] @@ -92,83 +93,15 @@ async def async_setup_entry( for area in lutron_client.areas: _LOGGER.debug("Working on area %s", area.name) for output in area.outputs: - platform = None - _LOGGER.debug("Working on output %s", output.type) - if output.type == "SYSTEM_SHADE": - entry_data.covers.append((area.name, output)) - platform = Platform.COVER - elif output.type == "CEILING_FAN_TYPE": - entry_data.fans.append((area.name, output)) - platform = Platform.FAN - elif output.is_dimmable: - entry_data.lights.append((area.name, output)) - platform = Platform.LIGHT - else: - entry_data.switches.append((area.name, output)) - platform = Platform.SWITCH - - _async_check_entity_unique_id( - hass, - entity_registry, - platform, - output.uuid, - output.legacy_uuid, - entry_data.client.guid, - ) - _async_check_device_identifiers( - hass, - device_registry, - output.uuid, - output.legacy_uuid, - entry_data.client.guid, + _setup_output( + hass, entry_data, output, area.name, entity_registry, device_registry ) for keypad in area.keypads: - _async_check_keypad_identifiers( - hass, - device_registry, - keypad.id, - keypad.uuid, - keypad.legacy_uuid, - entry_data.client.guid, + _setup_keypad( + hass, entry_data, keypad, area.name, entity_registry, device_registry ) - for button in keypad.buttons: - # If the button has a function assigned to it, add it as a scene - if button.name != "Unknown Button" and button.button_type in ( - "SingleAction", - "Toggle", - "SingleSceneRaiseLower", - "MasterRaiseLower", - "AdvancedToggle", - ): - # Associate an LED with a button if there is one - led = next( - (led for led in keypad.leds if led.number == button.number), - None, - ) - entry_data.scenes.append((area.name, keypad, button, led)) - platform = Platform.SCENE - _async_check_entity_unique_id( - hass, - entity_registry, - platform, - button.uuid, - button.legacy_uuid, - entry_data.client.guid, - ) - if led is not None: - platform = Platform.SWITCH - _async_check_entity_unique_id( - hass, - entity_registry, - platform, - led.uuid, - led.legacy_uuid, - entry_data.client.guid, - ) - if button.button_type: - entry_data.buttons.append((area.name, keypad, button)) if area.occupancy_group is not None: entry_data.binary_sensors.append((area.name, area.occupancy_group)) platform = Platform.BINARY_SENSOR @@ -202,6 +135,100 @@ async def async_setup_entry( return True +def _setup_output( + hass: HomeAssistant, + entry_data: LutronData, + output: Output, + area_name: str, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Set up a Lutron output.""" + _LOGGER.debug("Working on output %s", output.type) + if output.type == "SYSTEM_SHADE": + entry_data.covers.append((area_name, output)) + platform = Platform.COVER + elif output.type == "CEILING_FAN_TYPE": + entry_data.fans.append((area_name, output)) + platform = Platform.FAN + elif output.is_dimmable: + entry_data.lights.append((area_name, output)) + platform = Platform.LIGHT + else: + entry_data.switches.append((area_name, output)) + platform = Platform.SWITCH + + _async_check_entity_unique_id( + hass, + entity_registry, + platform, + output.uuid, + output.legacy_uuid, + entry_data.client.guid, + ) + _async_check_device_identifiers( + hass, + device_registry, + output.uuid, + output.legacy_uuid, + entry_data.client.guid, + ) + + +def _setup_keypad( + hass: HomeAssistant, + entry_data: LutronData, + keypad: Keypad, + area_name: str, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, +) -> None: + """Set up a Lutron keypad.""" + + _async_check_keypad_identifiers( + hass, + device_registry, + keypad.id, + keypad.uuid, + keypad.legacy_uuid, + entry_data.client.guid, + ) + leds_by_number = {led.number: led for led in keypad.leds} + for button in keypad.buttons: + # If the button has a function assigned to it, add it as a scene + if button.name != "Unknown Button" and button.button_type in ( + "SingleAction", + "Toggle", + "SingleSceneRaiseLower", + "MasterRaiseLower", + "AdvancedToggle", + ): + # Associate an LED with a button if there is one + led = leds_by_number.get(button.number) + entry_data.scenes.append((area_name, keypad, button, led)) + + _async_check_entity_unique_id( + hass, + entity_registry, + Platform.SCENE, + button.uuid, + button.legacy_uuid, + entry_data.client.guid, + ) + if led is not None: + for platform in (Platform.SWITCH, Platform.SELECT): + _async_check_entity_unique_id( + hass, + entity_registry, + platform, + led.uuid, + led.legacy_uuid, + entry_data.client.guid, + ) + if button.button_type: + entry_data.buttons.append((area_name, keypad, button)) + + def _async_check_entity_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/homeassistant/components/lutron/select.py b/homeassistant/components/lutron/select.py new file mode 100644 index 00000000000000..83dac04ae38989 --- /dev/null +++ b/homeassistant/components/lutron/select.py @@ -0,0 +1,73 @@ +"""Support for Lutron selects.""" + +from __future__ import annotations + +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 8dcaeffd0243a1..b64ba69dbc3e88 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_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index f8de5c60df0e0d..d643141bd6a668 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -1,5 +1,8 @@ -"""Support for Lutron Caseta Occupancy/Vacancy Sensors.""" +"""Support for Lutron Caseta Occupancy/Vacancy/Battery Sensors.""" +from __future__ import annotations + +from datetime import timedelta from typing import Any from pylutron_caseta import OCCUPANCY_GROUP_OCCUPIED @@ -8,7 +11,8 @@ 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 +20,13 @@ 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 +35,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 +45,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 +103,41 @@ def unique_id(self): 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/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index f163307a782a97..d5318742516111 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/lyric/api.py b/homeassistant/components/lyric/api.py index 7399e013b96280..5a08e626d3c2f6 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/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py index eb704a2d797bbc..e69dfa4338e5d8 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 daf5eb904ab477..3b3b0b182fd59c 100644 --- a/homeassistant/components/mailgun/notify.py +++ b/homeassistant/components/mailgun/notify.py @@ -44,6 +44,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/mastodon/const.py b/homeassistant/components/mastodon/const.py index 592b6a2300ebc1..805c01ad7627e4 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -15,6 +15,7 @@ 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_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/icons.json b/homeassistant/components/mastodon/icons.json index e9185ee13b18e2..dd2974378f0d11 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 2de970e263caa0..c34dc93b9885e2 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/services.py b/homeassistant/components/mastodon/services.py index 2208588570c2f6..018b91d80d3b42 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -4,6 +4,7 @@ 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 @@ 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.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 @@ class StatusVisibility(StrEnum): 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 @@ class StatusVisibility(StrEnum): } ) +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 bdeefc8b570870..0fce29eff419b7 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 5bfc629f1f3fbf..720c0c71851788 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/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index 08924645f6298b..77b8c551e0de79 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -127,6 +127,12 @@ class ConfigCommand(TypedDict, total=False): ) +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 +510,9 @@ async def _send_image( 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/matter/__init__.py b/homeassistant/components/matter/__init__.py index ae005ebbf05bd9..e3698efc7bd31b 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -16,7 +16,7 @@ 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 +35,7 @@ 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 +56,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 +74,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 +152,10 @@ async def on_hass_stop(event: Event) -> None: 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 +163,6 @@ async def on_hass_stop(event: Event) -> None: # 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 +173,7 @@ async def on_hass_stop(event: Event) -> None: async def _client_listen( hass: HomeAssistant, - entry: ConfigEntry, + entry: MatterConfigEntry, matter_client: MatterClient, init_ready: asyncio.Event, ) -> None: @@ -199,16 +195,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 +217,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 +241,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 +254,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 +283,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 dad780d9a8725f..c6ec4ece74b2e9 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -8,7 +8,6 @@ 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 +15,7 @@ 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 +37,7 @@ def __init__( 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/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 84ed60d580b8c9..3c7246a0569ee4 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -15,23 +15,22 @@ 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 11a364622e34ec..3801feaf61a14f 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -7,29 +7,29 @@ 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 +169,15 @@ async def async_press(self) -> None: 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 5614dd04fce030..7eb8edb263da5b 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -16,19 +16,22 @@ ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, + PRESET_AWAY, + PRESET_HOME, + PRESET_NONE, + PRESET_SLEEP, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, 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 @@ -42,6 +45,18 @@ HVACMode.FAN_ONLY: 7, } +# Map of Matter PresetScenarioEnum to HA standard preset constants or custom names +# This ensures presets are translated correctly using HA's translation system. +# kUserDefined scenarios always use device-provided names. +PRESET_SCENARIO_TO_HA_PRESET: dict[int, str] = { + clusters.Thermostat.Enums.PresetScenarioEnum.kOccupied: PRESET_HOME, + clusters.Thermostat.Enums.PresetScenarioEnum.kUnoccupied: PRESET_AWAY, + clusters.Thermostat.Enums.PresetScenarioEnum.kSleep: PRESET_SLEEP, + clusters.Thermostat.Enums.PresetScenarioEnum.kWake: "wake", + clusters.Thermostat.Enums.PresetScenarioEnum.kVacation: "vacation", + clusters.Thermostat.Enums.PresetScenarioEnum.kGoingToSleep: "going_to_sleep", +} + SINGLE_SETPOINT_DEVICES: set[tuple[int, int]] = { # Some devices only have a single setpoint while the matter spec # assumes that you need separate setpoints for heating and cooling. @@ -161,7 +176,6 @@ } SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum -ControlSequenceEnum = clusters.Thermostat.Enums.ControlSequenceOfOperationEnum ThermostatFeature = clusters.Thermostat.Bitmaps.Feature @@ -179,11 +193,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) @@ -197,10 +211,22 @@ class MatterClimate(MatterEntity, ClimateEntity): _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_hvac_mode: HVACMode = HVACMode.OFF + _matter_presets: list[clusters.Thermostat.Structs.PresetStruct] + _attr_preset_mode: str | None = None + _attr_preset_modes: list[str] | None = None _feature_map: int | None = None _platform_translation_key = "thermostat" + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the climate entity.""" + # Initialize preset handle mapping as instance attribute before calling super().__init__() + # because MatterEntity.__init__() calls _update_from_device() which needs this attribute + self._matter_presets = [] + self._preset_handle_by_name: dict[str, bytes | None] = {} + self._preset_name_by_handle: dict[bytes | None, str] = {} + super().__init__(*args, **kwargs) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) @@ -245,6 +271,34 @@ async def async_set_temperature(self, **kwargs: Any) -> None: matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, ) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + preset_handle = self._preset_handle_by_name[preset_mode] + + command = clusters.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_handle + ) + await self.send_device_command(command) + + # Optimistic update is required because Matter devices usually confirm + # preset changes asynchronously via a later attribute subscription. + # Additionally, some devices based on connectedhomeip do not send a + # subscription report for ActivePresetHandle after SetActivePresetRequest + # because thermostat-server-presets.cpp/SetActivePreset() updates the + # value without notifying the reporting engine. Keep this optimistic + # update as a workaround for that SDK bug and for normal report delays. + # Reference: project-chip/connectedhomeip, + # src/app/clusters/thermostat-server/thermostat-server-presets.cpp. + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + + # Keep the local ActivePresetHandle in sync until subscription update. + active_preset_path = create_attribute_path_from_attribute( + endpoint_id=self._endpoint.endpoint_id, + attribute=clusters.Thermostat.Attributes.ActivePresetHandle, + ) + self._endpoint.set_attribute_value(active_preset_path, preset_handle) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -269,10 +323,10 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: def _update_from_device(self) -> None: """Update from device.""" self._calculate_features() + self._attr_current_temperature = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.LocalTemperature ) - self._attr_current_humidity = ( int(raw_measured_humidity) / HUMIDITY_SCALING_FACTOR if ( @@ -284,6 +338,81 @@ def _update_from_device(self) -> None: else None ) + self._update_presets() + + self._update_hvac_mode_and_action() + self._update_target_temperatures() + self._update_temperature_limits() + + @callback + def _update_presets(self) -> None: + """Update preset modes and active preset.""" + # Check if the device supports presets feature before attempting to load. + # Use the already computed supported features instead of re-reading + # the FeatureMap attribute to keep a single source of truth and avoid + # casting None when the attribute is temporarily unavailable. + supported_features = self._attr_supported_features or 0 + if not (supported_features & ClimateEntityFeature.PRESET_MODE): + # Device does not support presets, skip preset update + self._preset_handle_by_name.clear() + self._preset_name_by_handle.clear() + self._attr_preset_modes = [] + self._attr_preset_mode = None + return + + self._matter_presets = ( + self.get_matter_attribute_value(clusters.Thermostat.Attributes.Presets) + or [] + ) + # Build preset mapping: use device-provided name if available, else generate unique name + self._preset_handle_by_name.clear() + self._preset_name_by_handle.clear() + if self._matter_presets: + used_names = set() + for i, preset in enumerate(self._matter_presets, start=1): + preset_translation = PRESET_SCENARIO_TO_HA_PRESET.get( + preset.presetScenario + ) + if preset_translation: + preset_name = preset_translation.lower() + else: + name = str(preset.name) if preset.name is not None else "" + name = name.strip() + if name: + preset_name = name + else: + # Ensure fallback name is unique + j = i + preset_name = f"Preset{j}" + while preset_name in used_names: + j += 1 + preset_name = f"Preset{j}" + used_names.add(preset_name) + preset_handle = ( + preset.presetHandle + if isinstance(preset.presetHandle, (bytes, type(None))) + else None + ) + self._preset_handle_by_name[preset_name] = preset_handle + self._preset_name_by_handle[preset_handle] = preset_name + + # Always include PRESET_NONE to allow users to clear the preset + self._preset_handle_by_name[PRESET_NONE] = None + self._preset_name_by_handle[None] = PRESET_NONE + + self._attr_preset_modes = list(self._preset_handle_by_name) + + # Update active preset mode + active_preset_handle = self.get_matter_attribute_value( + clusters.Thermostat.Attributes.ActivePresetHandle + ) + self._attr_preset_mode = self._preset_name_by_handle.get( + active_preset_handle, PRESET_NONE + ) + + @callback + def _update_hvac_mode_and_action(self) -> None: + """Update HVAC mode and action from device.""" if self.get_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False: # special case: the appliance has a dedicated Power switch on the OnOff cluster # if the mains power is off - treat it as if the HVAC mode is off @@ -335,7 +464,10 @@ def _update_from_device(self) -> None: self._attr_hvac_action = HVACAction.FAN else: self._attr_hvac_action = HVACAction.OFF - # update target temperature high/low + + @callback + def _update_target_temperatures(self) -> None: + """Update target temperature or temperature range.""" supports_range = ( self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -361,6 +493,9 @@ def _update_from_device(self) -> None: clusters.Thermostat.Attributes.OccupiedHeatingSetpoint ) + @callback + def _update_temperature_limits(self) -> None: + """Update min and max temperature limits.""" # update min_temp if self._attr_hvac_mode == HVACMode.COOL: attribute = clusters.Thermostat.Attributes.AbsMinCoolSetpointLimit @@ -400,6 +535,9 @@ def _calculate_features( self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF ) + if feature_map & ThermostatFeature.kPresets: + self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + # determine supported hvac modes if feature_map & ThermostatFeature.kHeating: self._attr_hvac_modes.append(HVACMode.HEAT) if feature_map & ThermostatFeature.kCooling: @@ -442,9 +580,13 @@ def _get_temperature_in_degrees( optional_attributes=( clusters.Thermostat.Attributes.FeatureMap, clusters.Thermostat.Attributes.ControlSequenceOfOperation, + clusters.Thermostat.Attributes.NumberOfPresets, clusters.Thermostat.Attributes.Occupancy, clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.Attributes.Presets, + clusters.Thermostat.Attributes.PresetTypes, + clusters.Thermostat.Attributes.ActivePresetHandle, clusters.Thermostat.Attributes.SystemMode, clusters.Thermostat.Attributes.ThermostatRunningMode, clusters.Thermostat.Attributes.ThermostatRunningState, @@ -456,5 +598,6 @@ def _get_temperature_in_degrees( ), device_type=(device_types.Thermostat, device_types.RoomAirConditioner), allow_multi=True, # also used for sensor entity + allow_none_value=True, ), ] diff --git a/homeassistant/components/matter/const.py b/homeassistant/components/matter/const.py index cb42401725a54a..939011281d1be0 100644 --- a/homeassistant/components/matter/const.py +++ b/homeassistant/components/matter/const.py @@ -1,6 +1,7 @@ """Constants for the Matter integration.""" import logging +from typing import Final from chip.clusters import Objects as clusters @@ -114,3 +115,5 @@ CRED_TYPE_FINGER_VEIN, CRED_TYPE_FACE, ] + +CONCENTRATION_BECQUERELS_PER_CUBIC_METER: Final = "Bq/m³" diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index 2d81577772a9dc..149e8709a6505e 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -17,14 +17,13 @@ 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 +53,11 @@ class OperationalStatus(IntEnum): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MatterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Matter Cover from Config Entry.""" - matter = get_matter(hass) + matter = config_entry.runtime_data.adapter matter.register_platform_handler(Platform.COVER, async_add_entities) diff --git a/homeassistant/components/matter/diagnostics.py b/homeassistant/components/matter/diagnostics.py index 23b6854c791dbd..1388addffd1474 100644 --- a/homeassistant/components/matter/diagnostics.py +++ b/homeassistant/components/matter/diagnostics.py @@ -9,11 +9,10 @@ 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 +40,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 +53,7 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, config_entry: MatterConfigEntry, device: dr.DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" matter = get_matter(hass) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 80a50491e46661..44e65f92da2de9 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -125,7 +125,9 @@ def __init__( 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 +214,24 @@ async def async_added_to_hass(self) -> None: 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 +257,22 @@ def name(self) -> str | UndefinedType | None: 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 d840daad8ba9b4..a640440c53d326 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -14,13 +14,12 @@ 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 +38,11 @@ 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 6195030a7405cf..167daa5e04da83 100644 --- a/homeassistant/components/matter/fan.py +++ b/homeassistant/components/matter/fan.py @@ -14,13 +14,12 @@ 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 +44,11 @@ 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 +253,10 @@ def _calculate_features( return self._feature_map = feature_map self._attr_supported_features = FanEntityFeature(0) + # Reset to default so a featuremap change from MultiSpeed -> non-MultiSpeed + # does not leave a stale speed_count / percentage_step. + self._attr_speed_count = 100 if feature_map & FanControlFeature.kMultiSpeed: - self._attr_supported_features |= FanEntityFeature.SET_SPEED self._attr_speed_count = int( self.get_matter_attribute_value(clusters.FanControl.Attributes.SpeedMax) ) @@ -305,8 +306,12 @@ def _calculate_features( if feature_map & FanControlFeature.kAirflowDirection: self._attr_supported_features |= FanEntityFeature.DIRECTION + # PercentSetting is always a mandatory attribute of the FanControl cluster, + # so percentage-based speed control is always available. self._attr_supported_features |= ( - FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON + FanEntityFeature.SET_SPEED + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON ) diff --git a/homeassistant/components/matter/helpers.py b/homeassistant/components/matter/helpers.py index fc06bfd4822ea1..99a9454f13293c 100644 --- a/homeassistant/components/matter/helpers.py +++ b/homeassistant/components/matter/helpers.py @@ -6,6 +6,7 @@ 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 +32,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/icons.json b/homeassistant/components/matter/icons.json index be65b462108085..e5645a7fcdd7e1 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -140,6 +140,9 @@ "pump_status": { "default": "mdi:pump" }, + "radon_concentration": { + "default": "mdi:radioactive" + }, "tank_percentage": { "default": "mdi:water-boiler" }, diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index 599f34bc9f4ff0..3103bdc5630801 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -23,7 +23,6 @@ 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 +30,7 @@ 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 +85,11 @@ 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 80316ea8014823..be577f6c09e763 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -15,7 +15,6 @@ 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 +33,7 @@ LOGGER, ) from .entity import MatterEntity, MatterEntityDescription -from .helpers import get_matter +from .helpers import MatterConfigEntry from .lock_helpers import ( DoorLockFeature, GetLockCredentialStatusResult, @@ -70,11 +69,11 @@ 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 1f95aba19877a9..4cfef792a7de11 100644 --- a/homeassistant/components/matter/lock_helpers.py +++ b/homeassistant/components/matter/lock_helpers.py @@ -71,6 +71,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 +117,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 +218,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 +823,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 +846,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 8274886cd11942..7fb7c3eaec79a2 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/number.py b/homeassistant/components/matter/number.py index 91b5fd05c4b390..375779bc99c383 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -17,7 +17,6 @@ NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -30,17 +29,17 @@ 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 +398,72 @@ def _update_from_device(self) -> None: ), 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 a0c87cc4974caa..481ebf6ade3b9f 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -11,13 +11,12 @@ 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 +65,11 @@ 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 +559,15 @@ def _update_from_device(self) -> None: 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 +587,14 @@ def _update_from_device(self) -> None: ), 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 +617,14 @@ def _update_from_device(self) -> None: 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 +644,7 @@ def _update_from_device(self) -> None: ), 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 6a0273e05bba08..707da2a197a3e8 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -23,7 +23,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, @@ -48,8 +47,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 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 = { @@ -224,11 +224,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) @@ -525,7 +525,6 @@ def _update_from_device(self) -> None: 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, @@ -553,7 +552,6 @@ def _update_from_device(self) -> None: 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, @@ -744,6 +742,19 @@ def _update_from_device(self) -> None: clusters.OzoneConcentrationMeasurement.Attributes.MeasuredValue, ), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="RadonSensor", + native_unit_of_measurement=CONCENTRATION_BECQUERELS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + translation_key="radon_concentration", + ), + entity_class=MatterSensor, + required_attributes=( + clusters.RadonConcentrationMeasurement.Attributes.MeasuredValue, + ), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( @@ -775,7 +786,6 @@ def _update_from_device(self) -> None: 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, @@ -792,7 +802,6 @@ def _update_from_device(self) -> None: 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, @@ -809,7 +818,6 @@ def _update_from_device(self) -> None: 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, @@ -824,7 +832,6 @@ def _update_from_device(self) -> None: 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, @@ -882,7 +889,6 @@ def _update_from_device(self) -> None: 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, @@ -1038,7 +1044,6 @@ def _update_from_device(self) -> None: 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, @@ -1058,7 +1063,6 @@ def _update_from_device(self) -> None: 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, @@ -1077,7 +1081,6 @@ def _update_from_device(self) -> None: 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/strings.json b/homeassistant/components/matter/strings.json index b8db87c58b8ecc..514ba606fa518e 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -141,11 +141,23 @@ }, "stop": { "name": "[%key:common::action::stop%]" + }, + "temporary_mute_request": { + "name": "Temporary mute" } }, "climate": { "thermostat": { - "name": "Thermostat" + "name": "Thermostat", + "state_attributes": { + "preset_mode": { + "state": { + "going_to_sleep": "Going to sleep", + "vacation": "Vacation", + "wake": "Wake" + } + } + } } }, "cover": { @@ -214,6 +226,12 @@ "cook_time": { "name": "Cooking time" }, + "detection_delay": { + "name": "Detection delay" + }, + "detection_threshold": { + "name": "Detection threshold" + }, "hold_time": { "name": "Hold time" }, @@ -244,6 +262,9 @@ "pump_setpoint": { "name": "Setpoint" }, + "sensitivity_level": { + "name": "Sensitivity" + }, "speaker_setpoint": { "name": "Volume" }, @@ -549,6 +570,9 @@ "pump_speed": { "name": "Rotation speed" }, + "radon_concentration": { + "name": "Radon concentration" + }, "reactive_current": { "name": "Reactive current" }, diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 7c125763703b49..9b757d8bbf3b70 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -15,13 +15,12 @@ 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 +33,11 @@ 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 +205,6 @@ def _update_from_device(self) -> None: device_types.Cooktop, device_types.Dishwasher, device_types.ExtractorHood, - device_types.HeatingCoolingUnit, device_types.LaundryDryer, device_types.LaundryWasher, device_types.Oven, @@ -241,7 +239,6 @@ def _update_from_device(self) -> None: device_types.Dishwasher, device_types.ExtractorHood, device_types.Fan, - device_types.HeatingCoolingUnit, device_types.LaundryDryer, device_types.LaundryWasher, device_types.Oven, @@ -319,4 +316,14 @@ def _update_from_device(self) -> None: 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 56d98f8b5b0fcc..eb817ba6ef8683 100644 --- a/homeassistant/components/matter/update.py +++ b/homeassistant/components/matter/update.py @@ -17,7 +17,6 @@ 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 +25,7 @@ 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 +58,11 @@ def from_dict(cls, data: dict[str, Any]) -> MatterUpdateExtraStoredData: 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/vacuum.py b/homeassistant/components/matter/vacuum.py index 30fa8a7fde37fd..c3d7f31eaa8327 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -17,14 +17,13 @@ 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 +54,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 f2deea97d7fc22..b6327d9d3931bf 100644 --- a/homeassistant/components/matter/valve.py +++ b/homeassistant/components/matter/valve.py @@ -13,13 +13,12 @@ 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 +27,11 @@ 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 5f3c0ce3a95e67..a6423c1d99a501 100644 --- a/homeassistant/components/matter/water_heater.py +++ b/homeassistant/components/matter/water_heater.py @@ -19,7 +19,6 @@ WaterHeaterEntityDescription, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_WHOLE, @@ -31,7 +30,7 @@ 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 +47,11 @@ 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) diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py index 642fa400213ff1..c4238017564ce0 100644 --- a/homeassistant/components/mcp/__init__.py +++ b/homeassistant/components/mcp/__init__.py @@ -7,6 +7,7 @@ from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow, llm from .application_credentials import authorization_server_context @@ -42,7 +43,14 @@ async def _create_token_manager( hass: HomeAssistant, entry: ModelContextProtocolConfigEntry ) -> TokenManager | None: """Create a OAuth token manager for the config entry if the server requires authentication.""" - if not (implementation := await async_get_config_entry_implementation(hass, entry)): + try: + implementation = await async_get_config_entry_implementation(hass, entry) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err + if not implementation: return None session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json index 1dcc4400ceddc7..c5e84505b417fd 100644 --- a/homeassistant/components/mcp/strings.json +++ b/homeassistant/components/mcp/strings.json @@ -56,5 +56,10 @@ } } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index 19ace718564f1e..980015bc3a444a 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -24,6 +24,8 @@ """ 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 @@ async def get(self, request: web.Request) -> web.StreamResponse: 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 @@ async def post(self, request: web.Request) -> web.StreamResponse: # 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 with asyncio.timeout(TIMEOUT), anyio.create_task_group() as tg: - tg.start_soon(run_server) + async def run_server() -> None: + await server.run( + streams.read_stream, streams.write_stream, options, stateless=True + ) - await streams.read_stream_writer.send(SessionMessage(message)) - session_message = await anext(streams.write_stream_reader) - tg.cancel_scope.cancel() + async with asyncio.timeout(TIMEOUT), anyio.create_task_group() as tg: + tg.start_soon(run_server) - _LOGGER.debug("Sending response: %s", session_message) - return web.json_response( - data=session_message.message.model_dump(by_alias=True, exclude_none=True), - ) + 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 2e4c645441b721..ca07e22e6808f1 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 907114f06cdd58..82ccbcd2cf13e2 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -10,10 +10,12 @@ 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 @@ _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 handle_get_prompt( ], ) + @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/manifest.json b/homeassistant/components/mealie/manifest.json index 6f9e61fd0fd893..b86669364f706c 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/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index ea9dd0adcfdd2b..a8b843b09c514b 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -59,7 +59,6 @@ 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 +245,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/condition.py b/homeassistant/components/media_player/condition.py index d63f569642ae34..2b405be804df46 100644 --- a/homeassistant/components/media_player/condition.py +++ b/homeassistant/components/media_player/condition.py @@ -1,34 +1,123 @@ """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_off": make_entity_state_condition(DOMAIN, MediaPlayerState.OFF), - "is_on": make_entity_state_condition( + "is_muted": MediaPlayerIsMutedCondition, + "is_not_playing": make_entity_state_condition( DOMAIN, { MediaPlayerState.BUFFERING, MediaPlayerState.IDLE, + MediaPlayerState.OFF, MediaPlayerState.ON, MediaPlayerState.PAUSED, - MediaPlayerState.PLAYING, }, ), - "is_not_playing": make_entity_state_condition( + "is_off": make_entity_state_condition(DOMAIN, MediaPlayerState.OFF), + "is_on": make_entity_state_condition( DOMAIN, { MediaPlayerState.BUFFERING, MediaPlayerState.IDLE, - MediaPlayerState.OFF, MediaPlayerState.ON, MediaPlayerState.PAUSED, + MediaPlayerState.PLAYING, }, ), "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 ace2747e81f38e..eb5c39cd5a72b5 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/icons.json b/homeassistant/components/media_player/icons.json index 94c0ced3778182..b767cc9904f047 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/strings.json b/homeassistant/components/media_player/strings.json index 3a3c4408f2d724..2347f3a2ecd5d3 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -1,14 +1,33 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "condition_threshold_name": "Threshold", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", + "trigger_threshold_name": "Threshold" }, "conditions": { + "is_muted": { + "description": "Tests if one or more media players are muted.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" + } + }, + "name": "Media player is muted" + }, "is_not_playing": { "description": "Tests if one or more media players are not playing.", "fields": { "behavior": { "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" } }, "name": "Media player is not playing" @@ -18,6 +37,9 @@ "fields": { "behavior": { "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" } }, "name": "Media player is off" @@ -27,6 +49,9 @@ "fields": { "behavior": { "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" } }, "name": "Media player is on" @@ -36,6 +61,9 @@ "fields": { "behavior": { "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" } }, "name": "Media player is paused" @@ -45,9 +73,39 @@ "fields": { "behavior": { "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" } }, "name": "Media player is playing" + }, + "is_unmuted": { + "description": "Tests if one or more media players are not muted.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" + } + }, + "name": "Media player is not muted" + }, + "is_volume": { + "description": "Tests the volume of one or more media players.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::condition_for_name%]" + }, + "threshold": { + "name": "[%key:component::media_player::common::condition_threshold_name%]" + } + }, + "name": "Volume" } }, "device_automation": { @@ -214,12 +272,6 @@ } }, "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, "enqueue": { "options": { "add": "Add to queue", @@ -234,13 +286,6 @@ "off": "[%key:common::state::off%]", "one": "Repeat one" } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } } }, "services": { @@ -433,14 +478,113 @@ }, "title": "Media player", "triggers": { + "muted": { + "description": "Triggers after one or more media players are muted.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::trigger_for_name%]" + } + }, + "name": "Media player muted" + }, + "paused_playing": { + "description": "Triggers after one or more media players pause playing.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::trigger_for_name%]" + } + }, + "name": "Media player paused playing" + }, + "started_playing": { + "description": "Triggers after one or more media players start playing.", + "fields": { + "behavior": { + "name": "[%key:component::media_player::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::media_player::common::trigger_for_name%]" + } + }, + "name": "Media player started playing" + }, "stopped_playing": { - "description": "Triggers after one or more media players stop playing media.", + "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 a39ccfa9ced2ee..9bdb20460d031f 100644 --- a/homeassistant/components/media_player/trigger.py +++ b/homeassistant/components/media_player/trigger.py @@ -1,12 +1,148 @@ """Provides triggers for media players.""" -from homeassistant.core import HomeAssistant -from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +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 if the origin state is valid and the state has changed.""" + if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return False + + 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 +156,32 @@ 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 cd63373a8ef895..fa6def22a3abeb 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/helper.py b/homeassistant/components/media_source/helper.py index 940b67c33c6c24..b4e6218a872272 100644 --- a/homeassistant/components/media_source/helper.py +++ b/homeassistant/components/media_source/helper.py @@ -8,7 +8,6 @@ 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 +36,6 @@ def _get_media_item( return item -@bind_hass async def async_browse_media( hass: HomeAssistant, media_content_id: str | None, @@ -71,7 +69,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/local_source.py b/homeassistant/components/media_source/local_source.py index b947adebad9aa9..a6e5141cbc7d3e 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -314,7 +314,7 @@ async def _validate_media_path(self, source_dir_id: str, location: str) -> Path: 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 +322,9 @@ async def head( 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/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 34ac5aea1cff47..d961b41a0e078c 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -19,7 +19,12 @@ 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 00000000000000..707b11475cd546 --- /dev/null +++ b/homeassistant/components/melcloud/binary_sensor.py @@ -0,0 +1,175 @@ +"""Support for MelCloud device binary sensors.""" + +from __future__ import annotations + +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/icons.json b/homeassistant/components/melcloud/icons.json index 7df606d4144720..90d0fe752a9818 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 b683ee6671a763..cd19d93145d788 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 f9bf1de42d89e8..45c14ef1569407 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -16,6 +16,7 @@ SensorStateClass, ) from homeassistant.const import ( + PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, UnitOfEnergy, @@ -34,7 +35,7 @@ class MelcloudSensorEntityDescription(SensorEntityDescription): """Describes Melcloud sensor entity.""" - value_fn: Callable[[Any], float] + value_fn: Callable[[Any], float | None] enabled: Callable[[Any], bool] @@ -45,8 +46,8 @@ class MelcloudSensorEntityDescription(SensorEntityDescription): 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 +55,8 @@ class MelcloudSensorEntityDescription(SensorEntityDescription): 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 +64,8 @@ class MelcloudSensorEntityDescription(SensorEntityDescription): 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 +75,8 @@ class MelcloudSensorEntityDescription(SensorEntityDescription): 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 +84,58 @@ class MelcloudSensorEntityDescription(SensorEntityDescription): 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 +143,9 @@ class MelcloudSensorEntityDescription(SensorEntityDescription): 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 +153,17 @@ class MelcloudSensorEntityDescription(SensorEntityDescription): 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 +171,80 @@ class MelcloudSensorEntityDescription(SensorEntityDescription): 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 +255,7 @@ class MelcloudSensorEntityDescription(SensorEntityDescription): 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 +263,8 @@ class MelcloudSensorEntityDescription(SensorEntityDescription): 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 +272,8 @@ class MelcloudSensorEntityDescription(SensorEntityDescription): 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 0af6c7a86479e8..2a8f1197ef472c 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/met/weather.py b/homeassistant/components/met/weather.py index 8d8317607be8f7..524d663bf65b53 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -48,6 +48,8 @@ ) from .coordinator import MetDataUpdateCoordinator, MetWeatherConfigEntry +PARALLEL_UPDATES = 0 + DEFAULT_NAME = "Met.no" diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 5d1274e085d7a6..91d1df4ec4ebd1 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 @@ -6,19 +7,14 @@ from meteofrance_api.helpers import is_valid_warning_department from requests import RequestException -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import ( - COORDINATOR_ALERT, - COORDINATOR_FORECAST, - COORDINATOR_RAIN, - DOMAIN, - PLATFORMS, -) +from .const import DOMAIN, PLATFORMS from .coordinator import ( MeteoFranceAlertUpdateCoordinator, + MeteoFranceConfigEntry, + MeteoFranceData, MeteoFranceForecastUpdateCoordinator, MeteoFranceRainUpdateCoordinator, ) @@ -26,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MeteoFranceConfigEntry) -> bool: """Set up a Meteo-France account from a config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -91,25 +87,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - hass.data[DOMAIN][entry.entry_id] = { - COORDINATOR_FORECAST: coordinator_forecast, - } - if coordinator_rain and coordinator_rain.last_update_success: - hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] = coordinator_rain - if coordinator_alert and coordinator_alert.last_update_success: - hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert + if coordinator_rain and not coordinator_rain.last_update_success: + coordinator_rain = None + if coordinator_alert and not coordinator_alert.last_update_success: + coordinator_alert = None + entry.runtime_data = MeteoFranceData( + forecast_coordinator=coordinator_forecast, + rain_coordinator=coordinator_rain, + alert_coordinator=coordinator_alert, + ) 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: MeteoFranceConfigEntry +) -> bool: """Unload a config entry.""" - if hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT]: - department = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR_FORECAST - ].data.position.get("dept") + if entry.runtime_data.alert_coordinator: + department = entry.runtime_data.forecast_coordinator.data.position.get("dept") hass.data[DOMAIN][department] = False _LOGGER.debug( ( @@ -121,13 +119,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) return unload_ok -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: MeteoFranceConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index 7a57048eb010dc..86819d825b7067 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -23,9 +23,6 @@ DOMAIN = "meteo_france" PLATFORMS = [Platform.SENSOR, Platform.WEATHER] -COORDINATOR_FORECAST = "coordinator_forecast" -COORDINATOR_RAIN = "coordinator_rain" -COORDINATOR_ALERT = "coordinator_alert" ATTRIBUTION = "Data provided by Météo-France" MODEL = "Météo-France mobile API" MANUFACTURER = "Météo-France" diff --git a/homeassistant/components/meteo_france/coordinator.py b/homeassistant/components/meteo_france/coordinator.py index 8d09a21e12d5e4..8c4db6fd87bb8e 100644 --- a/homeassistant/components/meteo_france/coordinator.py +++ b/homeassistant/components/meteo_france/coordinator.py @@ -1,5 +1,8 @@ """Support for Meteo-France weather data.""" +from __future__ import annotations + +from dataclasses import dataclass from datetime import timedelta import logging @@ -13,6 +16,18 @@ _LOGGER = logging.getLogger(__name__) +type MeteoFranceConfigEntry = ConfigEntry[MeteoFranceData] + + +@dataclass +class MeteoFranceData: + """Data for the Meteo-France integration.""" + + forecast_coordinator: MeteoFranceForecastUpdateCoordinator + rain_coordinator: MeteoFranceRainUpdateCoordinator | None + alert_coordinator: MeteoFranceAlertUpdateCoordinator | None + + SCAN_INTERVAL_RAIN = timedelta(minutes=5) SCAN_INTERVAL = timedelta(minutes=15) @@ -20,12 +35,12 @@ class MeteoFranceForecastUpdateCoordinator(DataUpdateCoordinator[Forecast]): """Coordinator for Meteo-France forecast data.""" - config_entry: ConfigEntry + config_entry: MeteoFranceConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: MeteoFranceConfigEntry, client: MeteoFranceClient, ) -> None: """Initialize the coordinator.""" @@ -50,12 +65,12 @@ async def _async_update_data(self) -> Forecast: class MeteoFranceRainUpdateCoordinator(DataUpdateCoordinator[Rain]): """Coordinator for Meteo-France rain data.""" - config_entry: ConfigEntry + config_entry: MeteoFranceConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: MeteoFranceConfigEntry, client: MeteoFranceClient, ) -> None: """Initialize the coordinator.""" @@ -80,12 +95,12 @@ async def _async_update_data(self) -> Rain: class MeteoFranceAlertUpdateCoordinator(DataUpdateCoordinator[CurrentPhenomenons]): """Coordinator for Meteo-France alert data.""" - config_entry: ConfigEntry + config_entry: MeteoFranceConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: MeteoFranceConfigEntry, client: MeteoFranceClient, department: str, ) -> None: diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 208cd5683500ae..226e99fdd5c2c4 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 1af9ead3a64278..75876153d2d06c 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -19,7 +19,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UV_INDEX, @@ -41,18 +40,11 @@ ATTR_NEXT_RAIN_1_HOUR_FORECAST, ATTR_NEXT_RAIN_DT_REF, ATTRIBUTION, - COORDINATOR_ALERT, - COORDINATOR_FORECAST, - COORDINATOR_RAIN, DOMAIN, MANUFACTURER, MODEL, ) -from .coordinator import ( - MeteoFranceAlertUpdateCoordinator, - MeteoFranceForecastUpdateCoordinator, - MeteoFranceRainUpdateCoordinator, -) +from .coordinator import MeteoFranceAlertUpdateCoordinator, MeteoFranceConfigEntry @dataclass(frozen=True, kw_only=True) @@ -188,20 +180,13 @@ class MeteoFranceSensorEntityDescription(SensorEntityDescription): async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MeteoFranceConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteo-France sensor platform.""" - data = hass.data[DOMAIN][entry.entry_id] - coordinator_forecast: MeteoFranceForecastUpdateCoordinator = data[ - COORDINATOR_FORECAST - ] - coordinator_rain: MeteoFranceRainUpdateCoordinator | None = data.get( - COORDINATOR_RAIN - ) - coordinator_alert: MeteoFranceAlertUpdateCoordinator | None = data.get( - COORDINATOR_ALERT - ) + coordinator_forecast = entry.runtime_data.forecast_coordinator + coordinator_rain = entry.runtime_data.rain_coordinator + coordinator_alert = entry.runtime_data.alert_coordinator entities: list[MeteoFranceSensor[Any]] = [ MeteoFranceSensor(coordinator_forecast, description) diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index a4a137ded83af0..6fb622a94e85ad 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -11,6 +11,7 @@ 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, @@ -18,7 +19,6 @@ WeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_MODE, UnitOfPrecipitationDepth, @@ -35,14 +35,13 @@ from .const import ( ATTRIBUTION, CONDITION_MAP, - COORDINATOR_FORECAST, DOMAIN, FORECAST_MODE_DAILY, FORECAST_MODE_HOURLY, MANUFACTURER, MODEL, ) -from .coordinator import MeteoFranceForecastUpdateCoordinator +from .coordinator import MeteoFranceConfigEntry, MeteoFranceForecastUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -58,13 +57,11 @@ def format_condition(condition: str, force_day: bool = False) -> str: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MeteoFranceConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteo-France weather platform.""" - coordinator: MeteoFranceForecastUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ][COORDINATOR_FORECAST] + coordinator = entry.runtime_data.forecast_coordinator async_add_entities( [ @@ -188,6 +185,9 @@ def _forecast(self, mode: str) -> list[Forecast]: 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/mfi/__init__.py b/homeassistant/components/mfi/__init__.py index de354dfbc37a66..df797caeeabf1c 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/miele/const.py b/homeassistant/components/miele/const.py index 015a6bbc5e5f96..b0461c8d3e77e3 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -479,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 diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index a723763ea35669..78782fd5de6a2d 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -59,6 +59,7 @@ PLATE_COUNT = { "KM7575": 6, + "KM7576": 6, "KM7678": 6, "KM7697": 6, "KM7699": 5, diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py index ce258712090254..e07d3c434b0bea 100644 --- a/homeassistant/components/mill/__init__.py +++ b/homeassistant/components/mill/__init__.py @@ -58,6 +58,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await data_coordinator.async_config_entry_first_refresh() + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data hass.data[DOMAIN][conn_type][key] = data_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py index 3a8535b811bf9c..2def1714e8f231 100644 --- a/homeassistant/components/mill/climate.py +++ b/homeassistant/components/mill/climate.py @@ -1,4 +1,5 @@ """Support for mill wifi-enabled home heaters.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from typing import Any diff --git a/homeassistant/components/mill/number.py b/homeassistant/components/mill/number.py index 8433a9853c6f5a..5691e0cfce4ab7 100644 --- a/homeassistant/components/mill/number.py +++ b/homeassistant/components/mill/number.py @@ -22,6 +22,8 @@ async def async_setup_entry( ) -> None: """Set up the Mill Number.""" if entry.data.get(CONNECTION_TYPE) == CLOUD: + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=hass-use-runtime-data mill_data_coordinator: MillDataUpdateCoordinator = hass.data[DOMAIN][CLOUD][ entry.data[CONF_USERNAME] ] diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py index 3a47cb427d2550..a2fd598044faae 100644 --- a/homeassistant/components/mill/sensor.py +++ b/homeassistant/components/mill/sensor.py @@ -1,4 +1,5 @@ """Support for mill wifi-enabled home heaters.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index f421be8cc83afa..9cac9f7a0149f3 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/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 2711f9457888c2..441022aaf6e741 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 .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 a4ed3ea598bd45..46c730a314ab90 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -82,6 +82,7 @@ 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_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 3e2c6b9f1d038c..159de66b481f6b 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -1,5 +1,6 @@ """Device tracker for Mobile app.""" +from collections.abc import Callable from typing import Any from homeassistant.components.device_tracker import ( @@ -53,11 +54,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 +133,7 @@ async def async_added_to_hass(self) -> None: self.update_data, ) - # Don't restore if we got set up with data. - if self._data is not None: - return - if (state := await self.async_get_last_state()) is None: - self._data = {} return attr = state.attributes @@ -158,7 +154,7 @@ async def async_will_remove_from_hass(self) -> None: 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 e97431baa13fb7..84527a528c0711 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -110,6 +110,8 @@ def _handle_update(self) -> None: 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 41cafa99e4325d..a0ee0ca524b107 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -170,6 +170,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/icons.json b/homeassistant/components/mobile_app/icons.json new file mode 100644 index 00000000000000..e4a00bd8427d74 --- /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/notify.py b/homeassistant/components/mobile_app/notify.py index 085c80afbebff3..d54c0949c758e6 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -1,4 +1,5 @@ """Support for mobile_app push notifications.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations @@ -8,7 +9,7 @@ import logging from typing import Any -import aiohttp +from aiohttp import ClientError, ClientSession from homeassistant.components.notify import ( ATTR_DATA, @@ -17,10 +18,19 @@ 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 +52,88 @@ 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 +147,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 +204,119 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None: 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) - - 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) + _LOGGER.error(str(e)) + + +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/strings.json b/homeassistant/components/mobile_app/strings.json index 2d49f8e3be136f..60ee8750c023e1 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 f139a203c345b9..3c52e858a39692 100644 --- a/homeassistant/components/mobile_app/util.py +++ b/homeassistant/components/mobile_app/util.py @@ -1,4 +1,5 @@ """Mobile app utility functions.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index cbbcd7710ee845..232c4c50c6c336 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -1,4 +1,5 @@ """Webhook handlers for mobile_app.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations @@ -413,7 +414,7 @@ async def webhook_render_template( { vol.Optional(ATTR_LOCATION_NAME): cv.string, vol.Optional(ATTR_GPS): cv.gps, - vol.Optional(ATTR_GPS_ACCURACY): cv.positive_int, + 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), diff --git a/homeassistant/components/mobile_app/websocket_api.py b/homeassistant/components/mobile_app/websocket_api.py index e862e4c8bd51f8..dec2d4cfa39935 100644 --- a/homeassistant/components/mobile_app/websocket_api.py +++ b/homeassistant/components/mobile_app/websocket_api.py @@ -1,4 +1,5 @@ """Mobile app websocket API.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/modem_callerid/config_flow.py b/homeassistant/components/modem_callerid/config_flow.py index 237fafa69d75dd..8d9cae02a639ba 100644 --- a/homeassistant/components/modem_callerid/config_flow.py +++ b/homeassistant/components/modem_callerid/config_flow.py @@ -5,8 +5,6 @@ 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 +17,11 @@ 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 +62,28 @@ async def async_step_user( 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 +93,7 @@ async def async_step_user( 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/moisture/conditions.yaml b/homeassistant/components/moisture/conditions.yaml index 2bdf154950c599..ff84dfa0e4492c 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 d125ccf9a5bdac..72ef64f7cf94e5 100644 --- a/homeassistant/components/moisture/strings.json +++ b/homeassistant/components/moisture/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -11,6 +13,9 @@ "fields": { "behavior": { "name": "[%key:component::moisture::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::moisture::common::condition_for_name%]" } }, "name": "Moisture is detected" @@ -20,6 +25,9 @@ "fields": { "behavior": { "name": "[%key:component::moisture::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::moisture::common::condition_for_name%]" } }, "name": "Moisture is not detected" @@ -30,6 +38,9 @@ "behavior": { "name": "[%key:component::moisture::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::moisture::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::moisture::common::condition_threshold_name%]" } @@ -37,21 +48,6 @@ "name": "Moisture level" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Moisture", "triggers": { "changed": { @@ -68,6 +64,9 @@ "fields": { "behavior": { "name": "[%key:component::moisture::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::moisture::common::trigger_for_name%]" } }, "name": "Moisture cleared" @@ -78,6 +77,9 @@ "behavior": { "name": "[%key:component::moisture::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::moisture::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::moisture::common::trigger_threshold_name%]" } @@ -89,6 +91,9 @@ "fields": { "behavior": { "name": "[%key:component::moisture::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::moisture::common::trigger_for_name%]" } }, "name": "Moisture detected" diff --git a/homeassistant/components/moisture/triggers.yaml b/homeassistant/components/moisture/triggers.yaml index a8225e53b7ec1f..040ce4c18572e2 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/monoprice/__init__.py b/homeassistant/components/monoprice/__init__.py index 1f5df2ca194c8a..3cd864f41493b2 100644 --- a/homeassistant/components/monoprice/__init__.py +++ b/homeassistant/components/monoprice/__init__.py @@ -12,13 +12,18 @@ 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 +35,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/media_player.py b/homeassistant/components/monoprice/media_player.py index 4561f29ba56612..fe3e158e163cf4 100644 --- a/homeassistant/components/monoprice/media_player.py +++ b/homeassistant/components/monoprice/media_player.py @@ -13,12 +13,11 @@ ) 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 @@ def restore(self): """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 00000000000000..4211c1e759c580 --- /dev/null +++ b/homeassistant/components/monoprice/services.py @@ -0,0 +1,30 @@ +"""Services for the monoprice integration.""" + +from __future__ import annotations + +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/motion/conditions.yaml b/homeassistant/components/motion/conditions.yaml index 5b9ef602e79059..4e6848a8f6a9fe 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 44f8703d83d5e8..4ce7c7fa2d1480 100644 --- a/homeassistant/components/motion/strings.json +++ b/homeassistant/components/motion/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_detected": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::motion::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::motion::common::condition_for_name%]" } }, "name": "Motion is detected" @@ -18,26 +23,14 @@ "fields": { "behavior": { "name": "[%key:component::motion::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::motion::common::condition_for_name%]" } }, "name": "Motion is not detected" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Motion", "triggers": { "cleared": { @@ -45,6 +38,9 @@ "fields": { "behavior": { "name": "[%key:component::motion::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::motion::common::trigger_for_name%]" } }, "name": "Motion cleared" @@ -54,6 +50,9 @@ "fields": { "behavior": { "name": "[%key:component::motion::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::motion::common::trigger_for_name%]" } }, "name": "Motion detected" diff --git a/homeassistant/components/motion/triggers.yaml b/homeassistant/components/motion/triggers.yaml index 1be6124ed17b30..39abe53c8a3a64 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 9c4d1a97f00107..e67a619e32291b 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -1,12 +1,11 @@ """The motion_blinds component.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern import asyncio import logging -from typing import TYPE_CHECKING from motionblinds import AsyncMotionMulticast -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -14,32 +13,28 @@ from .const import ( CONF_BLIND_TYPE_LIST, CONF_INTERFACE, - CONF_WAIT_FOR_PUSH, DEFAULT_INTERFACE, - DEFAULT_WAIT_FOR_PUSH, DOMAIN, - KEY_API_LOCK, - KEY_COORDINATOR, - KEY_GATEWAY, KEY_MULTICAST_LISTENER, KEY_SETUP_LOCK, KEY_UNSUB_STOP, PLATFORMS, ) -from .coordinator import DataUpdateCoordinatorMotionBlinds +from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry from .gateway import ConnectMotionGateway _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: MotionBlindsConfigEntry +) -> bool: """Set up the motion_blinds components from a config entry.""" hass.data.setdefault(DOMAIN, {}) setup_lock = hass.data[DOMAIN].setdefault(KEY_SETUP_LOCK, asyncio.Lock()) host = entry.data[CONF_HOST] key = entry.data[CONF_API_KEY] multicast_interface = entry.data.get(CONF_INTERFACE, DEFAULT_INTERFACE) - wait_for_push = entry.options.get(CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH) blind_type_list = entry.data.get(CONF_BLIND_TYPE_LIST) # Create multicast Listener @@ -88,15 +83,9 @@ def stop_motion_multicast(event): ): raise ConfigEntryNotReady motion_gateway = connect_gateway_class.gateway_device - api_lock = asyncio.Lock() - coordinator_info = { - KEY_GATEWAY: motion_gateway, - KEY_API_LOCK: api_lock, - CONF_WAIT_FOR_PUSH: wait_for_push, - } coordinator = DataUpdateCoordinatorMotionBlinds( - hass, entry, _LOGGER, coordinator_info + hass, entry, _LOGGER, motion_gateway ) # store blind type list for next time @@ -110,20 +99,16 @@ def stop_motion_multicast(event): # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = { - KEY_GATEWAY: motion_gateway, - KEY_COORDINATOR: coordinator, - } - - if TYPE_CHECKING: - assert entry.unique_id is not None + entry.runtime_data = coordinator 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: MotionBlindsConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS @@ -132,7 +117,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if unload_ok: multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER] multicast.Unregister_motion_gateway(config_entry.data[CONF_HOST]) - hass.data[DOMAIN].pop(config_entry.entry_id) if not hass.config_entries.async_loaded_entries(DOMAIN): # No motion gateways left, stop Motion multicast diff --git a/homeassistant/components/motion_blinds/button.py b/homeassistant/components/motion_blinds/button.py index 09f29e09c705f5..f590f50694c731 100644 --- a/homeassistant/components/motion_blinds/button.py +++ b/homeassistant/components/motion_blinds/button.py @@ -5,25 +5,23 @@ from motionblinds.motion_blinds import LimitStatus, MotionBlind 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 .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY -from .coordinator import DataUpdateCoordinatorMotionBlinds +from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry from .entity import MotionCoordinatorEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MotionBlindsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Motionblinds.""" entities: list[ButtonEntity] = [] - motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + coordinator = config_entry.runtime_data + motion_gateway = coordinator.gateway for blind in motion_gateway.device_list.values(): if blind.limit_status in ( diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index cd85de5c62787b..59a65aab0010bc 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -27,6 +26,7 @@ DEFAULT_WAIT_FOR_PUSH, DOMAIN, ) +from .coordinator import MotionBlindsConfigEntry from .gateway import ConnectMotionGateway _LOGGER = logging.getLogger(__name__) @@ -79,7 +79,7 @@ def __init__(self) -> None: @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: MotionBlindsConfigEntry, ) -> OptionsFlowHandler: """Get the options flow.""" return OptionsFlowHandler() diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index 950fa3ab4c74e8..1d151a1e63bc1f 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -16,7 +16,6 @@ KEY_GATEWAY = "gateway" KEY_API_LOCK = "api_lock" -KEY_COORDINATOR = "coordinator" KEY_MULTICAST_LISTENER = "multicast_listener" KEY_SETUP_LOCK = "setup_lock" KEY_UNSUB_STOP = "unsub_stop" diff --git a/homeassistant/components/motion_blinds/coordinator.py b/homeassistant/components/motion_blinds/coordinator.py index 8de793c405fbfa..6614b666538b02 100644 --- a/homeassistant/components/motion_blinds/coordinator.py +++ b/homeassistant/components/motion_blinds/coordinator.py @@ -1,11 +1,12 @@ """DataUpdateCoordinator for Motionblinds integration.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging -from typing import Any -from motionblinds import DEVICE_TYPES_WIFI, ParseException +from motionblinds import DEVICE_TYPES_WIFI, MotionGateway, ParseException from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -14,7 +15,7 @@ from .const import ( ATTR_AVAILABLE, CONF_WAIT_FOR_PUSH, - KEY_API_LOCK, + DEFAULT_WAIT_FOR_PUSH, KEY_GATEWAY, UPDATE_INTERVAL, UPDATE_INTERVAL_FAST, @@ -23,17 +24,20 @@ _LOGGER = logging.getLogger(__name__) +type MotionBlindsConfigEntry = ConfigEntry[DataUpdateCoordinatorMotionBlinds] + + class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator): """Class to manage fetching data from single endpoint.""" - config_entry: ConfigEntry + config_entry: MotionBlindsConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MotionBlindsConfigEntry, logger: logging.Logger, - coordinator_info: dict[str, Any], + gateway: MotionGateway, ) -> None: """Initialize global data updater.""" super().__init__( @@ -44,14 +48,16 @@ def __init__( update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - self.api_lock = coordinator_info[KEY_API_LOCK] - self._gateway = coordinator_info[KEY_GATEWAY] - self._wait_for_push = coordinator_info[CONF_WAIT_FOR_PUSH] + self.api_lock = asyncio.Lock() + self.gateway = gateway + self._wait_for_push = config_entry.options.get( + CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH + ) def update_gateway(self): """Fetch data from gateway.""" try: - self._gateway.Update() + self.gateway.Update() except TimeoutError, ParseException: # let the error be logged and handled by the motionblinds library return {ATTR_AVAILABLE: False} @@ -82,7 +88,7 @@ async def _async_update_data(self): self.update_gateway ) - for blind in self._gateway.device_list.values(): + for blind in self.gateway.device_list.values(): await asyncio.sleep(1.5) async with self.api_lock: data[blind.mac] = await self.hass.async_add_executor_job( diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index f1351af8bc21d0..342a00686d6c92 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -15,7 +15,6 @@ CoverEntity, CoverEntityFeature, ) -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 @@ -25,12 +24,11 @@ ATTR_ABSOLUTE_POSITION, ATTR_AVAILABLE, ATTR_WIDTH, - DOMAIN, - KEY_COORDINATOR, KEY_GATEWAY, SERVICE_SET_ABSOLUTE_POSITION, UPDATE_DELAY_STOP, ) +from .coordinator import MotionBlindsConfigEntry from .entity import MotionCoordinatorEntity _LOGGER = logging.getLogger(__name__) @@ -84,13 +82,13 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MotionBlindsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Motion Blind from a config entry.""" entities: list[MotionBaseDevice] = [] - motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + coordinator = config_entry.runtime_data + motion_gateway = coordinator.gateway for blind in motion_gateway.device_list.values(): if blind.type in POSITION_DEVICE_MAP: diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index eac89eccdd205f..673cbc5458e444 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -10,7 +10,6 @@ SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -19,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY +from .coordinator import MotionBlindsConfigEntry from .entity import MotionCoordinatorEntity ATTR_BATTERY_VOLTAGE = "battery_voltage" @@ -27,13 +26,13 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MotionBlindsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Perform the setup for Motionblinds.""" entities: list[SensorEntity] = [] - motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY] - coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] + coordinator = config_entry.runtime_data + motion_gateway = coordinator.gateway for blind in motion_gateway.device_list.values(): entities.append(MotionSignalStrengthSensor(coordinator, blind)) diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 5f3799abb1f90e..37ffe9bbd01088 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -45,7 +45,7 @@ async_register as webhook_register, async_unregister as webhook_unregister, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME, CONF_URL, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -80,7 +80,7 @@ WEB_HOOK_SENTINEL_KEY, WEB_HOOK_SENTINEL_VALUE, ) -from .coordinator import MotionEyeUpdateCoordinator +from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN] @@ -134,7 +134,7 @@ def is_acceptable_camera(camera: dict[str, Any] | None) -> bool: @callback def listen_for_new_cameras( hass: HomeAssistant, - entry: ConfigEntry, + entry: MotionEyeConfigEntry, add_func: Callable, ) -> None: """Listen for new cameras.""" @@ -168,7 +168,7 @@ def _add_camera( hass: HomeAssistant, device_registry: dr.DeviceRegistry, client: MotionEyeClient, - entry: ConfigEntry, + entry: MotionEyeConfigEntry, camera_id: int, camera: dict[str, Any], device_identifier: tuple[str, str], @@ -274,9 +274,8 @@ def _build_url( ) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) -> bool: """Set up motionEye from a config entry.""" - hass.data.setdefault(DOMAIN, {}) client = create_motioneye_client( entry.data[CONF_URL], @@ -306,7 +305,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator = MotionEyeUpdateCoordinator(hass, entry, client) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator current_cameras: set[tuple[str, str]] = set() device_registry = dr.async_get(hass) @@ -362,14 +361,13 @@ def _async_process_motioneye_cameras() -> None: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) -> bool: """Unload a config entry.""" webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - coordinator = hass.data[DOMAIN].pop(entry.entry_id) - await coordinator.client.async_client_close() + await entry.runtime_data.client.async_client_close() return unload_ok @@ -438,10 +436,14 @@ def _get_media_event_data( event_file_type: int, ) -> dict[str, str]: config_entry_id = next(iter(device.config_entries), None) - if not config_entry_id or config_entry_id not in hass.data[DOMAIN]: + if ( + not config_entry_id + or not (entry := hass.config_entries.async_get_entry(config_entry_id)) + or entry.state != ConfigEntryState.LOADED + ): return {} - coordinator = hass.data[DOMAIN][config_entry_id] + coordinator: MotionEyeUpdateCoordinator = entry.runtime_data client = coordinator.client for identifier in device.identifiers: diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index 65baa163e0a715..f18891c1d8c148 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -30,7 +30,6 @@ CONF_STILL_IMAGE_URL, MjpegCamera, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, @@ -50,14 +49,13 @@ CONF_STREAM_URL_TEMPLATE, CONF_SURVEILLANCE_PASSWORD, CONF_SURVEILLANCE_USERNAME, - DOMAIN, MOTIONEYE_MANUFACTURER, SERVICE_ACTION, SERVICE_SET_TEXT_OVERLAY, SERVICE_SNAPSHOT, TYPE_MOTIONEYE_MJPEG_CAMERA, ) -from .coordinator import MotionEyeUpdateCoordinator +from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator from .entity import MotionEyeEntity PLATFORMS = [Platform.CAMERA] @@ -92,11 +90,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MotionEyeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data @callback def camera_add(camera: dict[str, Any]) -> None: diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index 7ca6d9dfcebfee..d8036f8758f2ea 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -14,7 +14,6 @@ from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -39,6 +38,7 @@ DEFAULT_WEBHOOK_SET_OVERWRITE, DOMAIN, ) +from .coordinator import MotionEyeConfigEntry class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): @@ -180,7 +180,7 @@ async def async_step_hassio_confirm( @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: MotionEyeConfigEntry, ) -> MotionEyeOptionsFlow: """Get the Hyperion Options flow.""" return MotionEyeOptionsFlow() diff --git a/homeassistant/components/motioneye/coordinator.py b/homeassistant/components/motioneye/coordinator.py index 6e330d5d27bb66..601b132da12bd1 100644 --- a/homeassistant/components/motioneye/coordinator.py +++ b/homeassistant/components/motioneye/coordinator.py @@ -16,13 +16,16 @@ _LOGGER = logging.getLogger(__name__) +type MotionEyeConfigEntry = ConfigEntry[MotionEyeUpdateCoordinator] + + class MotionEyeUpdateCoordinator(DataUpdateCoordinator[dict[str, Any] | None]): """Coordinator for motionEye data.""" - config_entry: ConfigEntry + config_entry: MotionEyeConfigEntry def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, client: MotionEyeClient + self, hass: HomeAssistant, entry: MotionEyeConfigEntry, client: MotionEyeClient ) -> None: """Initialize the coordinator.""" super().__init__( diff --git a/homeassistant/components/motioneye/media_source.py b/homeassistant/components/motioneye/media_source.py index 52d4ca04530639..26674a6b6277d7 100644 --- a/homeassistant/components/motioneye/media_source.py +++ b/homeassistant/components/motioneye/media_source.py @@ -17,12 +17,13 @@ PlayMedia, Unresolvable, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from . import get_media_url, split_motioneye_device_identifier from .const import DOMAIN +from .coordinator import MotionEyeConfigEntry MIME_TYPE_MAP = { "movies": "video/mp4", @@ -74,7 +75,7 @@ async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: self._verify_kind_or_raise(kind) url = get_media_url( - self.hass.data[DOMAIN][config.entry_id].client, + config.runtime_data.client, self._get_camera_id_or_raise(config, device), self._get_path_or_raise(path), kind == "images", @@ -120,10 +121,10 @@ async def async_browse_media( return self._build_media_devices(config) return self._build_media_configs() - def _get_config_or_raise(self, config_id: str) -> ConfigEntry: + def _get_config_or_raise(self, config_id: str) -> MotionEyeConfigEntry: """Get a config entry from a URL.""" entry = self.hass.config_entries.async_get_entry(config_id) - if not entry: + if not entry or entry.state != ConfigEntryState.LOADED: raise MediaSourceError(f"Unable to find config entry with id: {config_id}") return entry @@ -154,7 +155,7 @@ def _get_path_or_raise(cls, path: str | None) -> str: @classmethod def _get_camera_id_or_raise( - cls, config: ConfigEntry, device: dr.DeviceEntry + cls, config: MotionEyeConfigEntry, device: dr.DeviceEntry ) -> int: """Get a config entry from a URL.""" for identifier in device.identifiers: @@ -164,7 +165,7 @@ def _get_camera_id_or_raise( raise MediaSourceError(f"Could not find camera id for device id: {device.id}") @classmethod - def _build_media_config(cls, config: ConfigEntry) -> BrowseMediaSource: + def _build_media_config(cls, config: MotionEyeConfigEntry) -> BrowseMediaSource: return BrowseMediaSource( domain=DOMAIN, identifier=config.entry_id, @@ -196,7 +197,7 @@ def _build_media_configs(self) -> BrowseMediaSource: @classmethod def _build_media_device( cls, - config: ConfigEntry, + config: MotionEyeConfigEntry, device: dr.DeviceEntry, full_title: bool = True, ) -> BrowseMediaSource: @@ -211,7 +212,7 @@ def _build_media_device( children_media_class=MediaClass.DIRECTORY, ) - def _build_media_devices(self, config: ConfigEntry) -> BrowseMediaSource: + def _build_media_devices(self, config: MotionEyeConfigEntry) -> BrowseMediaSource: """Build the media sources for device entries.""" device_registry = dr.async_get(self.hass) devices = dr.async_entries_for_config_entry(device_registry, config.entry_id) @@ -226,7 +227,7 @@ def _build_media_devices(self, config: ConfigEntry) -> BrowseMediaSource: @classmethod def _build_media_kind( cls, - config: ConfigEntry, + config: MotionEyeConfigEntry, device: dr.DeviceEntry, kind: str, full_title: bool = True, @@ -251,7 +252,7 @@ def _build_media_kind( ) def _build_media_kinds( - self, config: ConfigEntry, device: dr.DeviceEntry + self, config: MotionEyeConfigEntry, device: dr.DeviceEntry ) -> BrowseMediaSource: base = self._build_media_device(config, device) base.children = [ @@ -262,7 +263,7 @@ def _build_media_kinds( async def _build_media_path( self, - config: ConfigEntry, + config: MotionEyeConfigEntry, device: dr.DeviceEntry, kind: str, path: str, @@ -276,7 +277,7 @@ async def _build_media_path( base.children = [] - client = self.hass.data[DOMAIN][config.entry_id].client + client = config.runtime_data.client camera_id = self._get_camera_id_or_raise(config, device) if kind == "movies": @@ -286,7 +287,7 @@ async def _build_media_path( sub_dirs: set[str] = set() parts = parsed_path.parts - media_list = resp.get(KEY_MEDIA_LIST, []) + media_list = resp.get(KEY_MEDIA_LIST, []) if resp else [] def get_media_sort_key(media: dict) -> str: """Get media sort key.""" diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py index be3644451015bb..a8b14017de6971 100644 --- a/homeassistant/components/motioneye/sensor.py +++ b/homeassistant/components/motioneye/sensor.py @@ -9,24 +9,23 @@ from motioneye_client.const import KEY_ACTIONS 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 homeassistant.helpers.typing import StateType from . import get_camera_from_cameras, listen_for_new_cameras -from .const import DOMAIN, TYPE_MOTIONEYE_ACTION_SENSOR -from .coordinator import MotionEyeUpdateCoordinator +from .const import TYPE_MOTIONEYE_ACTION_SENSOR +from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator from .entity import MotionEyeEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MotionEyeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data @callback def camera_add(camera: dict[str, Any]) -> None: diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py index 4acaf54ae2077e..09aea463838e11 100644 --- a/homeassistant/components/motioneye/switch.py +++ b/homeassistant/components/motioneye/switch.py @@ -16,14 +16,13 @@ ) from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -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 get_camera_from_cameras, listen_for_new_cameras -from .const import DOMAIN, TYPE_MOTIONEYE_SWITCH_BASE -from .coordinator import MotionEyeUpdateCoordinator +from .const import TYPE_MOTIONEYE_SWITCH_BASE +from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator from .entity import MotionEyeEntity MOTIONEYE_SWITCHES = [ @@ -68,11 +67,11 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MotionEyeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up motionEye from a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data @callback def camera_add(camera: dict[str, Any]) -> None: diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py index 28fe921d9ac9a0..15277c8b8feac9 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/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index d6f14828050144..fb3d84041be086 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -311,6 +311,19 @@ def _platforms_in_use(hass: HomeAssistant, entry: ConfigEntry) -> set[str | Plat async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the actions and websocket API for the MQTT component.""" + if config.get(DOMAIN) and not mqtt_config_entry_enabled(hass): + issue_registry = ir.async_get(hass) + issue_registry.async_get_or_create( + DOMAIN, + "yaml_setup_without_active_setup", + is_fixable=False, + is_persistent=False, + severity=ir.IssueSeverity.WARNING, + learn_more_url="https://www.home-assistant.io/integrations/mqtt/" + "#configuration", + translation_key="yaml_setup_without_active_setup", + ) + websocket_api.async_register_command(hass, websocket_subscribe) websocket_api.async_register_command(hass, websocket_mqtt_info) diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index 4cc391e0ca7920..3ef84762be7540 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -18,6 +18,8 @@ "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 @@ "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/client.py b/homeassistant/components/mqtt/client.py index cbfaca71acf7aa..e2f944384cfc5c 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -45,7 +45,6 @@ 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 @@ -221,7 +220,6 @@ async def _sync_mqtt_subscribe(subscriptions: list[tuple[str, int]]) -> None: ) -@bind_hass async def async_subscribe( hass: HomeAssistant, topic: str, @@ -273,7 +271,6 @@ def async_subscribe_internal( return client.async_subscribe(topic, msg_callback, qos, encoding, job_type) -@bind_hass def subscribe( hass: HomeAssistant, topic: str, diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 8cbc9e1625a52e..4d013f0dc11508 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -477,7 +477,7 @@ "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 +1133,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 +1168,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 +1178,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 @@ -4984,7 +4991,9 @@ async def async_step_export_yaml( 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 +5042,9 @@ async def async_step_export_discovery( 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 diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 7244a41e975030..c342747deff65d 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -37,6 +37,8 @@ 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 +55,7 @@ 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 57d335685ebf97..1e163c6d41cc4d 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -401,6 +401,8 @@ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DATE, + Platform.DATETIME, Platform.DEVICE_TRACKER, Platform.EVENT, Platform.FAN, @@ -417,6 +419,7 @@ Platform.SIREN, Platform.SWITCH, Platform.TEXT, + Platform.TIME, Platform.UPDATE, Platform.VACUUM, Platform.VALVE, @@ -432,6 +435,8 @@ "camera", "climate", "cover", + "date", + "datetime", "device_automation", "device_tracker", "event", @@ -450,6 +455,7 @@ "switch", "tag", "text", + "time", "update", "vacuum", "valve", diff --git a/homeassistant/components/mqtt/date.py b/homeassistant/components/mqtt/date.py new file mode 100644 index 00000000000000..369b094c98abe1 --- /dev/null +++ b/homeassistant/components/mqtt/date.py @@ -0,0 +1,156 @@ +"""Support for MQTT date platform.""" + +from __future__ import annotations + +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 00000000000000..2e6cb9f04b55cd --- /dev/null +++ b/homeassistant/components/mqtt/datetime.py @@ -0,0 +1,201 @@ +"""Support for MQTT datetime platform.""" + +from __future__ import annotations + +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/entity.py b/homeassistant/components/mqtt/entity.py index aef54770476720..8a26e454e9f836 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -29,6 +29,7 @@ CONF_MODEL_ID, CONF_NAME, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, ) from homeassistant.core import Event, HassJobType, HomeAssistant, callback @@ -144,6 +145,7 @@ "entity_registry_enabled_default", "extra_state_attributes", "force_update", + "group_entities", "icon", "friendly_name", "should_poll", @@ -241,7 +243,7 @@ async def _async_setup_non_entity_entry_from_discovery( @callback -def async_setup_entity_entry_helper( +def async_setup_entity_entry_helper( # noqa: C901 hass: HomeAssistant, entry: ConfigEntry, entity_class: type[MqttEntity] | None, @@ -390,6 +392,8 @@ def _async_setup_entities() -> None: 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) @@ -1472,6 +1476,7 @@ async def async_added_to_hass(self) -> None: 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: @@ -1489,6 +1494,12 @@ async def mqtt_async_added_to_hass(self) -> None: 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: @@ -1499,6 +1510,7 @@ async def discovery_update(self, discovery_payload: MQTTDiscoveryPayload) -> Non 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) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index a50c39aa5ea258..0b0ca17c994e94 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" @@ -1141,6 +1141,10 @@ } }, "title": "MQTT device \"{name}\" subentry migration to YAML" + }, + "yaml_setup_without_active_setup": { + "description": "Home Assistant detected manually configured MQTT items, but these items cannot be loaded because MQTT is not set up correctly. Make sure the MQTT broker is set up correctly, or remove the MQTT configuration from your `configuration.yaml` file and restart Home Assistant to fix this issue.", + "title": "MQTT is not set up correctly" } }, "options": { diff --git a/homeassistant/components/mqtt/time.py b/homeassistant/components/mqtt/time.py new file mode 100644 index 00000000000000..86de4cea8cf976 --- /dev/null +++ b/homeassistant/components/mqtt/time.py @@ -0,0 +1,156 @@ +"""Support for MQTT time platform.""" + +from __future__ import annotations + +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/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 6896d51ef93c6d..fb1166250f10a5 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -10,12 +10,13 @@ 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 +28,14 @@ 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 +54,8 @@ 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 +141,22 @@ def services_to_strings( 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 +182,10 @@ def services_to_strings( } ).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 +212,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 +252,14 @@ def _strings_to_services( 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 +293,24 @@ def _state_message_received(self, msg: ReceiveMessage) -> None: 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 +326,20 @@ async def _subscribe_topics(self) -> None: """(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/mullvad/__init__.py b/homeassistant/components/mullvad/__init__.py index dad0506ff82c8e..aa50de8cd638e8 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 3984b2fec08026..b40facffeafd98 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 c0d56abba2b96a..0fc021b1065bd0 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -49,7 +49,14 @@ 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/entity.py b/homeassistant/components/music_assistant/entity.py index 21fc072a639090..c12bf107fc027d 100644 --- a/homeassistant/components/music_assistant/entity.py +++ b/homeassistant/components/music_assistant/entity.py @@ -6,8 +6,9 @@ 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 +85,45 @@ async def __on_mass_update(self, event: MassEvent) -> None: 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/manifest.json b/homeassistant/components/music_assistant/manifest.json index f5dc0e4a8d14d0..e89498303860f4 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.3"], + "requirements": ["music-assistant-client==1.3.5"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 8eb13002fd9c3a..6268e10d70d532 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -131,6 +131,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 +141,7 @@ def __init__(self, mass: MusicAssistantClient, player_id: str) -> None: 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 +220,23 @@ async def async_on_update(self) -> None: 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 +416,16 @@ async def async_select_source(self, source: str) -> None: ) 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 +711,6 @@ def _set_supported_features(self) -> None: 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 00000000000000..626c05a9cd11cc --- /dev/null +++ b/homeassistant/components/music_assistant/number.py @@ -0,0 +1,119 @@ +"""Music Assistant Number platform.""" + +from __future__ import annotations + +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/select.py b/homeassistant/components/music_assistant/select.py new file mode 100644 index 00000000000000..bc47b7b006a041 --- /dev/null +++ b/homeassistant/components/music_assistant/select.py @@ -0,0 +1,123 @@ +"""Music Assistant select platform.""" + +from __future__ import annotations + +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/strings.json b/homeassistant/components/music_assistant/strings.json index 57c5e1745b4387..299e7d8caa63bc 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 00000000000000..dfc76540dc0592 --- /dev/null +++ b/homeassistant/components/music_assistant/switch.py @@ -0,0 +1,106 @@ +"""Music Assistant Switch platform.""" + +from __future__ import annotations + +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 00000000000000..b8699640d32ef2 --- /dev/null +++ b/homeassistant/components/music_assistant/text.py @@ -0,0 +1,93 @@ +"""Music Assistant text platform.""" + +from __future__ import annotations + +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 8c1347b2b04e65..4921ec1f821976 100644 --- a/homeassistant/components/mutesync/__init__.py +++ b/homeassistant/components/mutesync/__init__.py @@ -2,32 +2,26 @@ 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 66fe78e931cb93..34ba8100443cf1 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 @@ 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/coordinator.py b/homeassistant/components/mutesync/coordinator.py index 03c545c7e24b17..2e4925edd5624a 100644 --- a/homeassistant/components/mutesync/coordinator.py +++ b/homeassistant/components/mutesync/coordinator.py @@ -15,18 +15,20 @@ 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/myneomitis/__init__.py b/homeassistant/components/myneomitis/__init__.py index ab27ae01585385..58a986e651631a 100644 --- a/homeassistant/components/myneomitis/__init__.py +++ b/homeassistant/components/myneomitis/__init__.py @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SELECT] +PLATFORMS = [Platform.CLIMATE, Platform.SELECT] @dataclass @@ -114,6 +114,14 @@ async def _async_disconnect_websocket(_event: Event) -> None: 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 00000000000000..01c6771aa7f822 --- /dev/null +++ b/homeassistant/components/myneomitis/climate.py @@ -0,0 +1,360 @@ +"""Climate entities for MyNeomitis integration.""" + +from __future__ import annotations + +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 8814be2396dafd..0d198a3fa5fcf0 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 b9dfa39dd83533..0a314a42e5602f 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/strings.json b/homeassistant/components/myneomitis/strings.json index 59edeafd0ff2e7..e768bc7c287f17 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/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index e2aca8b9f0161c..482d2caf9efc62 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -1,4 +1,5 @@ """Connect to a MySensors gateway via pymysensors API.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/mysensors/entity.py b/homeassistant/components/mysensors/entity.py index 5caa42c282c0fa..5419415bc7feaf 100644 --- a/homeassistant/components/mysensors/entity.py +++ b/homeassistant/components/mysensors/entity.py @@ -1,4 +1,5 @@ """Handle MySensors devices.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 91453ea33065a2..8ee53252157b65 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -284,6 +284,8 @@ def gateway_connected(_: BaseAsyncGateway) -> None: 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/helpers.py b/homeassistant/components/mysensors/helpers.py index 3c9b841bdb339e..4361c0c2c83566 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -62,6 +62,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/sensor.py b/homeassistant/components/mysensors/sensor.py index 836070f4a095c5..132b57c40d8258 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -230,6 +230,8 @@ def async_node_discover(discovery_info: NodeDiscoveryInfo) -> None: """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/mystrom/config_flow.py b/homeassistant/components/mystrom/config_flow.py index 38b292e9f97827..c7126dc0aca4d0 100644 --- a/homeassistant/components/mystrom/config_flow.py +++ b/homeassistant/components/mystrom/config_flow.py @@ -3,7 +3,7 @@ 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 +11,7 @@ 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 +32,8 @@ class MyStromConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _host: str | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -51,3 +54,38 @@ async def async_step_user( 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/manifest.json b/homeassistant/components/mystrom/manifest.json index 2cab6ec12f617d..fc8dc8cba123e2 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/strings.json b/homeassistant/components/mystrom/strings.json index 2466f5f0d3cdfc..b4c8669386614e 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/nam/sensor.py b/homeassistant/components/nam/sensor.py index e59d111e5e553d..e50bf595a6d77b 100644 --- a/homeassistant/components/nam/sensor.py +++ b/homeassistant/components/nam/sensor.py @@ -358,8 +358,7 @@ class NAMSensorEntityDescription(SensorEntityDescription): ), 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 83913110d4528b..f1aa0311f2fca5 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/neato/__init__.py b/homeassistant/components/neato/__init__.py index 318396d6a8a674..9ba9164bdbea6a 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -24,12 +24,14 @@ from homeassistant.helpers.typing import ConfigType from . import api -from .const import DOMAIN, NEATO_LOGIN +from .const import DOMAIN from .hub import NeatoHub from .services import async_setup_services _LOGGER = logging.getLogger(__name__) +type NeatoConfigEntry = ConfigEntry[NeatoHub] + CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [ Platform.BUTTON, @@ -46,9 +48,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: NeatoConfigEntry) -> bool: """Set up config entry.""" - hass.data.setdefault(DOMAIN, {}) if CONF_TOKEN not in entry.data: raise ConfigEntryAuthFailed @@ -69,7 +70,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady from ex neato_session = api.ConfigEntryAuth(hass, entry, implementation) - hass.data[DOMAIN][entry.entry_id] = neato_session hub = NeatoHub(hass, Account(neato_session)) await hub.async_update_entry_unique_id(entry) @@ -80,17 +80,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Failed to connect to Neato API") raise ConfigEntryNotReady from ex - hass.data[NEATO_LOGIN] = hub + 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: NeatoConfigEntry) -> bool: """Unload 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/neato/button.py b/homeassistant/components/neato/button.py index 8658dfd1b1b213..2afaca890007c4 100644 --- a/homeassistant/components/neato/button.py +++ b/homeassistant/components/neato/button.py @@ -5,22 +5,21 @@ from pybotvac import Robot 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 .const import NEATO_ROBOTS +from . import NeatoConfigEntry from .entity import NeatoEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NeatoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato button from config entry.""" - entities = [NeatoDismissAlertButton(robot) for robot in hass.data[NEATO_ROBOTS]] + entities = [NeatoDismissAlertButton(robot) for robot in entry.runtime_data.robots] async_add_entities(entities, True) diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index 42278a3a48f6a9..4234867be99b37 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -11,11 +11,11 @@ from urllib3.response import HTTPResponse 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 NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from . import NeatoConfigEntry +from .const import SCAN_INTERVAL_MINUTES from .entity import NeatoEntity from .hub import NeatoHub @@ -27,15 +27,14 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NeatoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato camera with config entry.""" - neato: NeatoHub = hass.data[NEATO_LOGIN] - mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA) + hub = entry.runtime_data dev = [ - NeatoCleaningMap(neato, robot, mapdata) - for robot in hass.data[NEATO_ROBOTS] + NeatoCleaningMap(hub, robot, hub.map_data) + for robot in hub.robots if "maps" in robot.traits ] @@ -51,9 +50,7 @@ class NeatoCleaningMap(NeatoEntity, Camera): _attr_translation_key = "cleaning_map" - def __init__( - self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any] | None - ) -> None: + def __init__(self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any]) -> None: """Initialize Neato cleaning map.""" super().__init__(robot) Camera.__init__(self) diff --git a/homeassistant/components/neato/const.py b/homeassistant/components/neato/const.py index 2237096282c486..f875d9086cfbc4 100644 --- a/homeassistant/components/neato/const.py +++ b/homeassistant/components/neato/const.py @@ -3,10 +3,6 @@ DOMAIN = "neato" CONF_VENDOR = "vendor" -NEATO_LOGIN = "neato_login" -NEATO_MAP_DATA = "neato_map_data" -NEATO_PERSISTENT_MAPS = "neato_persistent_maps" -NEATO_ROBOTS = "neato_robots" SCAN_INTERVAL_MINUTES = 1 diff --git a/homeassistant/components/neato/hub.py b/homeassistant/components/neato/hub.py index fd5f045c30f21c..9410e60ad0936c 100644 --- a/homeassistant/components/neato/hub.py +++ b/homeassistant/components/neato/hub.py @@ -1,7 +1,10 @@ """Support for Neato botvac connected vacuum cleaners.""" +from __future__ import annotations + from datetime import timedelta import logging +from typing import Any from pybotvac import Account from urllib3.response import HTTPResponse @@ -10,8 +13,6 @@ from homeassistant.core import HomeAssistant from homeassistant.util import Throttle -from .const import NEATO_MAP_DATA, NEATO_PERSISTENT_MAPS, NEATO_ROBOTS - _LOGGER = logging.getLogger(__name__) @@ -22,14 +23,17 @@ def __init__(self, hass: HomeAssistant, neato: Account) -> None: """Initialize the Neato hub.""" self._hass = hass self.my_neato: Account = neato + self.robots: set[Any] = set() + self.persistent_maps: dict[str, Any] = {} + self.map_data: dict[str, Any] = {} @Throttle(timedelta(minutes=1)) def update_robots(self) -> None: """Update the robot states.""" - _LOGGER.debug("Running HUB.update_robots %s", self._hass.data.get(NEATO_ROBOTS)) - self._hass.data[NEATO_ROBOTS] = self.my_neato.robots - self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps - self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps + _LOGGER.debug("Running HUB.update_robots %s", self.robots) + self.robots = self.my_neato.robots + self.persistent_maps = self.my_neato.persistent_maps + self.map_data = self.my_neato.maps def download_map(self, url: str) -> HTTPResponse: """Download a new map image.""" diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json index 577a515bf4df6f..37886a921a7475 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 4be02fe1ef73b4..6ec28dba7fe9c9 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -10,12 +10,12 @@ from pybotvac.robot import Robot from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -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 NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from . import NeatoConfigEntry +from .const import SCAN_INTERVAL_MINUTES from .entity import NeatoEntity from .hub import NeatoHub @@ -28,12 +28,12 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NeatoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Neato sensor using config entry.""" - neato: NeatoHub = hass.data[NEATO_LOGIN] - dev = [NeatoSensor(neato, robot) for robot in hass.data[NEATO_ROBOTS]] + hub = entry.runtime_data + dev = [NeatoSensor(hub, robot) for robot in hub.robots] if not dev: return diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index 1ae06fef44cf4b..df0aba9787ed23 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -10,12 +10,12 @@ from pybotvac.robot import Robot from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES +from . import NeatoConfigEntry +from .const import SCAN_INTERVAL_MINUTES from .entity import NeatoEntity from .hub import NeatoHub @@ -30,14 +30,14 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NeatoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato switch with config entry.""" - neato: NeatoHub = hass.data[NEATO_LOGIN] + hub = entry.runtime_data dev = [ - NeatoConnectedSwitch(neato, robot, type_name) - for robot in hass.data[NEATO_ROBOTS] + NeatoConnectedSwitch(hub, robot, type_name) + for robot in hub.robots for type_name in SWITCH_TYPES ] diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 571eb25df6c10c..02d2e40b4db39a 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -15,22 +15,12 @@ VacuumActivity, VacuumEntityFeature, ) -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 .const import ( - ACTION, - ALERTS, - ERRORS, - MODE, - NEATO_LOGIN, - NEATO_MAP_DATA, - NEATO_PERSISTENT_MAPS, - NEATO_ROBOTS, - SCAN_INTERVAL_MINUTES, -) +from . import NeatoConfigEntry +from .const import ACTION, ALERTS, ERRORS, MODE, SCAN_INTERVAL_MINUTES from .entity import NeatoEntity from .hub import NeatoHub @@ -52,16 +42,16 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NeatoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Neato vacuum with config entry.""" - neato: NeatoHub = hass.data[NEATO_LOGIN] - mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA) - persistent_maps: dict[str, Any] | None = hass.data.get(NEATO_PERSISTENT_MAPS) + hub = entry.runtime_data dev = [ - NeatoConnectedVacuum(neato, robot, mapdata, persistent_maps) - for robot in hass.data[NEATO_ROBOTS] + NeatoConnectedVacuum( + hub, robot, hub.map_data or None, hub.persistent_maps or None + ) + for robot in hub.robots ] if not dev: diff --git a/homeassistant/components/nest/event.py b/homeassistant/components/nest/event.py index 9bb041fce6c6aa..eed45bdc8f8890 100644 --- a/homeassistant/components/nest/event.py +++ b/homeassistant/components/nest/event.py @@ -8,6 +8,7 @@ from google_nest_sdm.traits import TraitType from homeassistant.components.event import ( + DoorbellEventType, EventDeviceClass, EventEntity, EventEntityDescription, @@ -42,7 +43,7 @@ class NestEventEntityDescription(EventEntityDescription): 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 @@ async def _async_handle_event(self, event_message: EventMessage) -> None: # 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/strings.json b/homeassistant/components/nest/strings.json index e15c7f2dcb7a50..aa4490d03f92c0 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 f11325b02bf033..4b9a4fad4d4d94 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations -from http import HTTPStatus import logging import secrets from typing import Any -import aiohttp +from aiohttp import ClientError import pyatmo from homeassistant.components import cloud @@ -16,10 +15,14 @@ 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 ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + OAuth2TokenRequestError, + OAuth2TokenRequestReauthError, +) from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import ( ImplementationUnavailableError, @@ -34,12 +37,10 @@ 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, @@ -48,7 +49,7 @@ 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__) @@ -60,6 +61,8 @@ 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: {}, @@ -72,7 +75,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) @@ -89,14 +92,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session = OAuth2Session(hass, entry, implementation) try: await session.async_ensure_token_valid() - except aiohttp.ClientResponseError as ex: - _LOGGER.warning("API error: %s (%s)", ex.status, ex.message) - if ex.status in ( - HTTPStatus.BAD_REQUEST, - HTTPStatus.UNAUTHORIZED, - HTTPStatus.FORBIDDEN, - ): - raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + except OAuth2TokenRequestReauthError as ex: + raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex + except (OAuth2TokenRequestError, ClientError) as ex: raise ConfigEntryNotReady from ex required_scopes = api.get_api_scopes(entry.data["auth_implementation"]) @@ -107,14 +105,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( @@ -130,7 +126,7 @@ async def unregister_webhook( ) 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] @@ -166,7 +162,7 @@ async def register_webhook( ) 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) @@ -200,7 +196,9 @@ async def manage_cloudhook(state: cloud.CloudConnectionState) -> None: 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( @@ -212,32 +210,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: @@ -250,10 +243,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 c550c31c4a6c90..21fbff3fc72487 100644 --- a/homeassistant/components/netatmo/binary_sensor.py +++ b/homeassistant/components/netatmo/binary_sensor.py @@ -13,7 +13,6 @@ 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 @@ 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__) @@ -180,7 +179,7 @@ class NetatmoBinarySensorEntityDescription(BinarySensorEntityDescription): 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 e77b5188067b65..288a7664eb1d4f 100644 --- a/homeassistant/components/netatmo/button.py +++ b/homeassistant/components/netatmo/button.py @@ -7,13 +7,12 @@ 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 _LOGGER = logging.getLogger(__name__) @@ -21,7 +20,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo button platform.""" diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index e0d84784ee8279..2973c078511df9 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -1,4 +1,5 @@ """Support for the Netatmo cameras.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations @@ -11,7 +12,6 @@ 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,13 +35,14 @@ 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 _LOGGER = logging.getLogger(__name__) @@ -51,7 +52,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo camera platform.""" @@ -175,18 +176,16 @@ def handle_event(self, event: dict) -> None: 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 +225,20 @@ def supported_features(self) -> CameraEntityFeature: 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 +262,10 @@ def async_update_callback(self) -> None: 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 +273,6 @@ def async_update_callback(self) -> None: 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 a74ed630a4b718..88b72ad97d885a 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -1,4 +1,5 @@ """Support for Netatmo Smart thermostats.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations @@ -20,7 +21,6 @@ HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_HALVES, @@ -54,7 +54,7 @@ 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 _LOGGER = logging.getLogger(__name__) @@ -120,7 +120,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo energy platform.""" diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index b33d4898832985..812f8fbb3c0554 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -9,12 +9,7 @@ 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 +26,7 @@ CONF_WEATHER_AREAS, DOMAIN, ) +from .data_handler import NetatmoConfigEntry _LOGGER = logging.getLogger(__name__) @@ -45,7 +41,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 +95,7 @@ async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult: 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 9a95cd36fed3e8..b10d2309770d65 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -27,11 +27,9 @@ 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 @@ "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_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 a599aacd719ea8..eafc573829d4d8 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -13,13 +13,12 @@ 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 _LOGGER = logging.getLogger(__name__) @@ -27,7 +26,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo cover platform.""" diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index 31845e1c0c7c42..4961ea3bf973d3 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -1,4 +1,5 @@ """The Netatmo data handler.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations @@ -27,20 +28,20 @@ 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 +90,8 @@ } SCAN_INTERVAL = 60 +type NetatmoConfigEntry = ConfigEntry[NetatmoDataHandler] + @dataclass class NetatmoDevice: @@ -138,11 +141,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 +179,7 @@ async def async_setup(self) -> None: ) ) - self.account = pyatmo.AsyncAccount(self._auth) + self.account = pyatmo.AsyncAccount(self.auth) await self.subscribe(ACCOUNT, ACCOUNT, None) @@ -365,13 +373,14 @@ def setup_modules(self, home: pyatmo.Home, signal_home: str) -> None: 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 +433,7 @@ def setup_rooms(self, home: pyatmo.Home, signal_home: str) -> None: 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/diagnostics.py b/homeassistant/components/netatmo/diagnostics.py index 8cb07d1f9d821b..50f58ab1891229 100644 --- a/homeassistant/components/netatmo/diagnostics.py +++ b/homeassistant/components/netatmo/diagnostics.py @@ -5,11 +5,9 @@ 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 +30,10 @@ 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 2d12631a3db0f9..ae823adfda375c 100644 --- a/homeassistant/components/netatmo/entity.py +++ b/homeassistant/components/netatmo/entity.py @@ -140,6 +140,8 @@ async def async_added_to_hass(self) -> None: 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 b0dc74c2b5827c..aefb47a995b451 100644 --- a/homeassistant/components/netatmo/fan.py +++ b/homeassistant/components/netatmo/fan.py @@ -8,13 +8,12 @@ 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 _LOGGER = logging.getLogger(__name__) @@ -27,7 +26,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo fan platform.""" diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py index 4d4c4ba9509454..cd7a688db41cb9 100644 --- a/homeassistant/components/netatmo/light.py +++ b/homeassistant/components/netatmo/light.py @@ -8,7 +8,6 @@ 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 +21,7 @@ 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 +29,7 @@ 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/media_source.py b/homeassistant/components/netatmo/media_source.py index f92214c90f5233..8fa8fa4625562a 100644 --- a/homeassistant/components/netatmo/media_source.py +++ b/homeassistant/components/netatmo/media_source.py @@ -1,4 +1,5 @@ """Netatmo Media Source Implementation.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py index cb6675e412979d..28f2b42189f6c6 100644 --- a/homeassistant/components/netatmo/select.py +++ b/homeassistant/components/netatmo/select.py @@ -1,11 +1,11 @@ """Support for the Netatmo climate schedule selector.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations 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 +19,7 @@ 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 +27,7 @@ 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 56b8233912f56a..a77765a5a08f81 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -4,11 +4,13 @@ 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 +18,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -42,18 +43,27 @@ 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 +128,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 +301,7 @@ class NetatmoSensorEntityDescription(SensorEntityDescription): 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,74 +397,168 @@ class NetatmoPublicWeatherSensorEntityDescription(SensorEntityDescription): ), ) -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"): - return - entity = NetatmoClimateBatterySensor(netatmo_device) - async_add_entities([entity]) + def _create_base_sensor_entity( + sensorClass: type[NetatmoBaseSensor], + descriptions: dict[NetatmoDeviceCategory, list[NetatmoSensorEntityDescription]], + netatmo_device: NetatmoDevice, + ) -> None: + """Create sensor entities for a Netatmo device.""" - entry.async_on_unload( - async_dispatcher_connect(hass, NETATMO_CREATE_BATTERY, _create_battery_entity) - ) + if netatmo_device.device.device_category is None: + return - @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] = [] + + # 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, + ) + ) - @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 + 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), + ) ) - entry.async_on_unload( - async_dispatcher_connect(hass, NETATMO_CREATE_SENSOR, _create_sensor_entity) - ) - @callback def _create_room_sensor_entity(netatmo_device: NetatmoRoom) -> None: if not netatmo_device.room.climate_type: 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 +569,7 @@ def _create_room_sensor_entity(netatmo_device: NetatmoRoom) -> None: ) 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 +626,54 @@ async def add_public_entities(update: bool = True) -> None: 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 +684,7 @@ def __init__( 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 +694,22 @@ def available(self) -> bool: """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 +717,53 @@ def async_update_callback(self) -> None: 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 +783,13 @@ def async_update_callback(self) -> None: 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 +797,47 @@ def __init__( 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 9ee37c11528a5d..4a37acac425fb4 100644 --- a/homeassistant/components/netatmo/switch.py +++ b/homeassistant/components/netatmo/switch.py @@ -8,13 +8,12 @@ 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 _LOGGER = logging.getLogger(__name__) @@ -22,7 +21,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: NetatmoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Netatmo switch platform.""" diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py index 7a56085469154d..396651b9e3533a 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/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index aa7664a77a8ae5..3b07dc237b3730 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/network/__init__.py b/homeassistant/components/network/__init__.py index dd5344faa56e57..acfbcfe17bf243 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -10,7 +10,6 @@ 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 +41,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 +53,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 +87,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 +124,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/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index 168488e19402cc..afb161d91267a8 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 2e184e13fc7c31..bed57b9383aa34 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -31,6 +31,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/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index bdda0d30356b04..aae4b9d43c3234 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 ccb882509f6ea9..34893702b5dfcb 100644 --- a/homeassistant/components/nfandroidtv/config_flow.py +++ b/homeassistant/components/nfandroidtv/config_flow.py @@ -26,24 +26,42 @@ async def async_step_user( 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/strings.json b/homeassistant/components/nfandroidtv/strings.json index 531a6af1617bba..79c9648942b7d8 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 ac201ed2322695..6fc5ea49d97ca4 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -7,7 +7,6 @@ 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 +29,7 @@ CONF_WORD_SWAP, DOMAIN, ) -from .coordinator import CoilCoordinator +from .coordinator import CoilCoordinator, NibeHeatpumpConfigEntry PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, @@ -45,7 +44,9 @@ 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 +84,7 @@ async def _async_stop(_): 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 +113,8 @@ def _on_product_info(product_info: ProductInfo): 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 d49862180bdcd5..4245a1c7652613 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -5,24 +5,22 @@ 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 8b6c8abf3598d8..3d63da77f1637b 100644 --- a/homeassistant/components/nibe_heatpump/button.py +++ b/homeassistant/components/nibe_heatpump/button.py @@ -6,24 +6,23 @@ 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 1b8a0ecc0df3ef..19dcca2362a479 100644 --- a/homeassistant/components/nibe_heatpump/climate.py +++ b/homeassistant/components/nibe_heatpump/climate.py @@ -24,31 +24,29 @@ 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/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index 05e652d7f42f91..edd0439de54221 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -28,6 +28,8 @@ from .const import DOMAIN, LOGGER +type NibeHeatpumpConfigEntry = ConfigEntry[CoilCoordinator] + class ContextCoordinator[_DataTypeT, _ContextTypeT](DataUpdateCoordinator[_DataTypeT]): """Update coordinator with context adjustments.""" @@ -73,12 +75,12 @@ def release_update(): 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/number.py b/homeassistant/components/nibe_heatpump/number.py index 59f365f52bf470..b1857067df846f 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -5,24 +5,22 @@ 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 c92c12a882a356..fa0c936ec5c4aa 100644 --- a/homeassistant/components/nibe_heatpump/select.py +++ b/homeassistant/components/nibe_heatpump/select.py @@ -5,24 +5,22 @@ 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 54cd0f7ea34c54..92afbbf4bcdbe7 100644 --- a/homeassistant/components/nibe_heatpump/sensor.py +++ b/homeassistant/components/nibe_heatpump/sensor.py @@ -11,7 +11,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -28,8 +27,7 @@ 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 +183,12 @@ 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 452244f05b58a4..42a104e1f30bbd 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -7,24 +7,22 @@ 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 a72851e7eab61b..72be4503fe8700 100644 --- a/homeassistant/components/nibe_heatpump/water_heater.py +++ b/homeassistant/components/nibe_heatpump/water_heater.py @@ -14,29 +14,27 @@ 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/nightscout/__init__.py b/homeassistant/components/nightscout/__init__.py index 798fcf1ec9dbbe..9e01a2712abd13 100644 --- a/homeassistant/components/nightscout/__init__.py +++ b/homeassistant/components/nightscout/__init__.py @@ -16,8 +16,10 @@ 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 de1dadf1143af6..126a568a1d1352 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -10,12 +10,12 @@ 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 +26,11 @@ 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/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json index 0e06a62eacffc1..b86d83cb8d99a3 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/nina/__init__.py b/homeassistant/components/nina/__init__.py index 4bb435ea1cecd1..544402b0b3dda2 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -18,7 +18,7 @@ ) 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 cfbdd87a0e2c98..3f351c0b6f4500 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -1,4 +1,4 @@ -"""NINA sensor platform.""" +"""NINA binary sensor platform.""" from __future__ import annotations @@ -8,11 +8,8 @@ BinarySensorDeviceClass, 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 ( ATTR_AFFECTED_AREAS, @@ -28,13 +25,13 @@ ATTR_WEB, CONF_MESSAGE_SLOTS, CONF_REGIONS, - DOMAIN, ) from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator +from .entity import NinaEntity async def async_setup_entry( - hass: HomeAssistant, + _: HomeAssistant, config_entry: NinaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: @@ -46,7 +43,7 @@ async def async_setup_entry( message_slots: int = config_entry.data[CONF_MESSAGE_SLOTS] async_add_entities( - NINAMessage(coordinator, ent, regions[ent], i + 1, config_entry) + NINAMessage(coordinator, ent, regions[ent], i + 1) for ent in coordinator.data for i in range(message_slots) ) @@ -55,7 +52,7 @@ async def async_setup_entry( PARALLEL_UPDATES = 0 -class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEntity): +class NINAMessage(NinaEntity, BinarySensorEntity): """Representation of an NINA warning.""" _attr_device_class = BinarySensorDeviceClass.SAFETY @@ -67,31 +64,20 @@ def __init__( region: str, region_name: str, slot_id: int, - config_entry: ConfigEntry, ) -> None: """Initialize.""" - super().__init__(coordinator) + super().__init__(coordinator, region, region_name, slot_id) - self._region = region - self._warning_index = slot_id - 1 - - self._attr_name = f"Warning: {region_name} {slot_id}" + self._attr_translation_key = "warning" self._attr_unique_id = f"{region}-{slot_id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, config_entry.entry_id)}, - manufacturer="NINA", - entry_type=DeviceEntryType.SERVICE, - ) @property def is_on(self) -> bool: """Return the state of the sensor.""" - if len(self.coordinator.data[self._region]) <= self._warning_index: + if self._get_active_warnings_count() <= self._warning_index: return False - data = self.coordinator.data[self._region][self._warning_index] - - return data.is_valid + return self._get_warning_data().is_valid @property def extra_state_attributes(self) -> dict[str, Any]: @@ -99,18 +85,22 @@ def extra_state_attributes(self) -> dict[str, Any]: if not self.is_on: return {} - data = self.coordinator.data[self._region][self._warning_index] + 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 2eeec4de19d4f0..f00f8918298eab 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -31,6 +31,7 @@ CONST_REGIONS, DOMAIN, NO_MATCH_REGEX, + SENSOR_SUFFIXES, ) @@ -243,32 +244,7 @@ async def async_step_init( 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 +263,35 @@ async def async_step_init( 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 409658e4131574..d034303a243dcd 100644 --- a/homeassistant/components/nina/const.py +++ b/homeassistant/components/nina/const.py @@ -15,6 +15,8 @@ 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 +36,17 @@ 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 175b128fdba972..12e4e831dc6cf9 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -4,6 +4,7 @@ import asyncio from dataclasses import dataclass +from datetime import datetime import re from typing import Any @@ -35,13 +36,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 @@ -139,18 +141,33 @@ def _parse_data(self) -> dict[str, list[NinaWarningData]]: ) 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 new file mode 100644 index 00000000000000..c5b462fcd7ac46 --- /dev/null +++ b/homeassistant/components/nina/entity.py @@ -0,0 +1,44 @@ +"""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 + + +class NinaEntity(CoordinatorEntity[NINADataUpdateCoordinator]): + """Base class for NINA entities.""" + + def __init__( + self, + coordinator: NINADataUpdateCoordinator, + region: str, + region_name: str, + slot_id: int, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._region = region + self._warning_index = slot_id - 1 + self._region_name = region_name + + self._attr_translation_placeholders = { + "slot_id": str(slot_id), + } + + 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.""" + return len(self.coordinator.data[self._region]) + + def _get_warning_data(self) -> NinaWarningData: + """Return warning data.""" + return self.coordinator.data[self._region][self._warning_index] diff --git a/homeassistant/components/nina/quality_scale.yaml b/homeassistant/components/nina/quality_scale.yaml index 1d405b9e8cbbef..45d3e909d5e6fa 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 00000000000000..d1491d6365b381 --- /dev/null +++ b/homeassistant/components/nina/sensor.py @@ -0,0 +1,159 @@ +"""NINA sensor platform.""" + +from __future__ import annotations + +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 b022563f9df17b..2e36e2fd61e0b8 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -45,6 +45,45 @@ } } }, + "entity": { + "binary_sensor": { + "warning": { + "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}" + } + } + }, "options": { "abort": { "no_fetch": "[%key:component::nina::config::abort::no_fetch%]", diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index fda6ec08b45863..540d82dff9ad94 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -39,6 +39,8 @@ 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 +87,25 @@ def __init__(self) -> None: _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 +147,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @callback def _async_untrack_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: """Remove tracking for devices owned by this config entry.""" + # Uses legacy hass.data[DOMAIN] pattern + # pylint: disable-next=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 7bde59b768ee96..cf9c455bda8727 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -16,7 +16,6 @@ ) from homeassistant.components.network import MDNS_TARGET_IP from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -26,6 +25,7 @@ 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 +167,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 +186,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 +261,8 @@ def _async_is_unique_host_list(self, user_input: dict[str, Any]) -> bool: @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 afac3f0643555a..26762577007d77 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -6,24 +6,28 @@ 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 4a2783143ca7c3..b141cf3114a402 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/strings.json b/homeassistant/components/nmbs/strings.json index 918087b8d33017..d7bf9ad6209abd 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/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 7c886c534cbb44..cff8f29149c8c2 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -5,31 +5,81 @@ 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_AUTO_DISCOVERED, + 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() - - hass.data.setdefault(DOMAIN, {}) + stored_ip = entry.data[CONF_IP_ADDRESS] + auto_discovered = entry.data[CONF_AUTO_DISCOVERED] + + 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: + if not auto_discovered: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect_manual", + translation_placeholders={"serial": serial, "ip": stored_ip}, + ) from err + # Stored IP may be stale for an auto-discovered entry - try UDP + # rediscovery to pick up a new DHCP lease. + discovered = await nobo.async_discover_hubs(serial=serial) + if not discovered: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="hub_not_found", + translation_placeholders={"serial": serial}, + ) 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_rediscovered", + translation_placeholders={"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 +88,19 @@ async def _async_close(event): 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 +109,24 @@ async def _async_close(event): 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 + ) + + return True diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 018f3e2b06ade5..9415618de75133 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -17,13 +17,13 @@ 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 +32,9 @@ DOMAIN, OVERRIDE_TYPE_NOW, ) +from .entity import NoboBaseEntity + +PARALLEL_UPDATES = 0 SUPPORT_FLAGS = ( ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -45,13 +48,13 @@ 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 +66,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 +74,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 +83,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 +100,6 @@ def __init__(self, zone_id, hub: nobo, override_type) -> None: ) 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 +134,6 @@ async def async_set_temperature(self, **kwargs: Any) -> None: 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 +145,11 @@ async def async_update(self) -> None: @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 +176,3 @@ def _read_state(self) -> None: 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 05ece456f15258..2839d4280e954f 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -17,7 +16,9 @@ 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, @@ -35,6 +36,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Nobø Ecohub.""" VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize the config flow.""" @@ -172,7 +174,7 @@ def _hubs(self): @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 +207,11 @@ async def async_step_init(self, user_input=None) -> ConfigFlowResult: 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 fdffb97720184e..bf7fe018f502fa 100644 --- a/homeassistant/components/nobo_hub/const.py +++ b/homeassistant/components/nobo_hub/const.py @@ -5,8 +5,8 @@ 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 00000000000000..0acec4a5feea7d --- /dev/null +++ b/homeassistant/components/nobo_hub/entity.py @@ -0,0 +1,40 @@ +"""Base entity for the Nobø Ecohub integration.""" + +from __future__ import annotations + +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 566ff88abaca07..4e11f049b55c7b 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -5,13 +5,13 @@ 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 +21,20 @@ 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 +49,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 +65,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 +78,6 @@ def __init__(self, hub: nobo, override_type) -> None: 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 +94,25 @@ async def async_update(self) -> None: @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 +121,6 @@ def __init__(self, zone_id: str, hub: nobo) -> None: 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 +137,12 @@ async def async_update(self) -> None: @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 +151,3 @@ def _read_state(self) -> None: 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 6a394f23f4c1dd..0c8c9bc2b431fe 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -9,25 +9,28 @@ 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 +39,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 +68,16 @@ def __init__(self, serial: str, hub: nobo) -> None: ) 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 5323ee239654b2..791ccc4cbda37e 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -15,18 +15,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 +57,35 @@ } } }, + "exceptions": { + "cannot_connect_manual": { + "message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}. If the hub has moved to a new IP address, remove and re-add the integration." + }, + "cannot_connect_rediscovered": { + "message": "Unable to connect to Nobø Ecohub at rediscovered IP {ip}; will retry." + }, + "hub_not_found": { + "message": "Nobø Ecohub with serial {serial} not found on the network. The hub may be offline or on a different subnet; will retry." + } + }, "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/config_flow.py b/homeassistant/components/nordpool/config_flow.py index b3b807badad352..415628cf45da89 100644 --- a/homeassistant/components/nordpool/config_flow.py +++ b/homeassistant/components/nordpool/config_flow.py @@ -56,6 +56,8 @@ 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/coordinator.py b/homeassistant/components/nordpool/coordinator.py index f2f41322aff225..3fc346ff7547a7 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -108,11 +108,11 @@ async def handle_data(self, initial: bool = False) -> 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 +158,11 @@ async def api_call(self, retry: int = 3) -> DeliveryPeriodsData | None: 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] diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json index 1ac32f28763b38..85e43a3545c442 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/strings.json b/homeassistant/components/nordpool/strings.json index 89e99c37908c04..88706a9fbbdc04 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": { diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index f5703022e1230a..7cfda555077192 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -14,7 +14,7 @@ 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 +159,6 @@ async def async_platform_discovered( ] -@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 +172,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/notion/__init__.py b/homeassistant/components/notion/__init__.py index 79f5d951e7e80d..aef7d740860232 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -9,7 +9,6 @@ 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 +17,6 @@ from .const import ( CONF_REFRESH_TOKEN, CONF_USER_UUID, - DOMAIN, LOGGER, SENSOR_BATTERY, SENSOR_DOOR, @@ -31,7 +29,7 @@ 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 +65,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 +117,7 @@ def async_save_refresh_token(refresh_token: str) -> None: 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 +154,6 @@ def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None 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 5552305e867f48..24b60088e6a95d 100644 --- a/homeassistant/components/notion/binary_sensor.py +++ b/homeassistant/components/notion/binary_sensor.py @@ -12,13 +12,11 @@ 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 +28,7 @@ SENSOR_SMOKE_CO, SENSOR_WINDOW_HINGED, ) -from .coordinator import NotionDataUpdateCoordinator +from .coordinator import NotionConfigEntry from .entity import NotionEntity, NotionEntityDescription @@ -108,11 +106,11 @@ class NotionBinarySensorDescription( 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/coordinator.py b/homeassistant/components/notion/coordinator.py index d77bfa95f4796d..136644bcfb5142 100644 --- a/homeassistant/components/notion/coordinator.py +++ b/homeassistant/components/notion/coordinator.py @@ -28,10 +28,12 @@ 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 @@ def asdict(self) -> dict[str, Any]: 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 424e5f7d0acc32..7963f7db4ac96b 100644 --- a/homeassistant/components/notion/diagnostics.py +++ b/homeassistant/components/notion/diagnostics.py @@ -5,12 +5,11 @@ 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 +33,10 @@ 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/sensor.py b/homeassistant/components/notion/sensor.py index 24496c8391ae9e..bae095ad1a4fdd 100644 --- a/homeassistant/components/notion/sensor.py +++ b/homeassistant/components/notion/sensor.py @@ -10,13 +10,12 @@ 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 @@ class NotionSensorDescription(SensorEntityDescription, NotionEntityDescription): 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 00000000000000..4e21a91fb91cf1 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/__init__.py @@ -0,0 +1,20 @@ +"""The Novy Cooker Hood integration.""" + +from __future__ import annotations + +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 00000000000000..976bb3ae0c9e43 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/commands.py @@ -0,0 +1,16 @@ +"""Helpers for loading Novy cooker-hood RF commands.""" + +from __future__ import annotations + +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 00000000000000..d1dd5f0314823c --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow for the Novy Cooker Hood integration.""" + +from __future__ import annotations + +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 00000000000000..0d4c06154f2edd --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/const.py @@ -0,0 +1,21 @@ +"""Constants for the Novy Cooker Hood integration.""" + +from __future__ import annotations + +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 00000000000000..8673eb4be074bd --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/entity.py @@ -0,0 +1,76 @@ +"""Common entity for the Novy Cooker Hood integration.""" + +from __future__ import annotations + +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 00000000000000..287ce19c88ddad --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/fan.py @@ -0,0 +1,143 @@ +"""Fan platform for the Novy Cooker Hood (calibrated speed control).""" + +from __future__ import annotations + +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 00000000000000..9061a275458066 --- /dev/null +++ b/homeassistant/components/novy_cooker_hood/light.py @@ -0,0 +1,67 @@ +"""Light platform for the Novy Cooker Hood.""" + +from __future__ import annotations + +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 00000000000000..92a53f4c2624af --- /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 00000000000000..93a6fc2a244f29 --- /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 00000000000000..1a546af6fb9fa0 --- /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/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index f033f1e836961c..c59fac55a88118 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["aiontfy"], "quality_scale": "platinum", - "requirements": ["aiontfy==0.8.4"] + "requirements": ["aiontfy==0.8.5"] } diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py index d23ebcc8b167fe..56ae547b2918de 100644 --- a/homeassistant/components/ntfy/notify.py +++ b/homeassistant/components/ntfy/notify.py @@ -65,11 +65,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/nuheat/__init__.py b/homeassistant/components/nuheat/__init__.py index 21c7ca79a1fad9..ca72d4906aee7b 100644 --- a/homeassistant/components/nuheat/__init__.py +++ b/homeassistant/components/nuheat/__init__.py @@ -6,13 +6,12 @@ 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 e666e4be0cd03f..4625614e773b62 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -18,7 +18,6 @@ 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.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 @@ 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 6555f7376ed116..e1c61bbf1cc816 100644 --- a/homeassistant/components/nuheat/coordinator.py +++ b/homeassistant/components/nuheat/coordinator.py @@ -16,15 +16,18 @@ 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 6e89fd074b9c7f..ae7f9fb4140904 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass from http import HTTPStatus import logging @@ -14,7 +13,6 @@ 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 +26,7 @@ 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 +34,12 @@ 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 +51,14 @@ async def handle_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 +143,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 +209,7 @@ async def _stop_nuki(_: Event): ) 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 +224,15 @@ async def _stop_nuki(_: Event): 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 +244,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 7ba908c13e48ff..247ebfe0d71069 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -9,23 +9,21 @@ 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 cccff99e3974ca..36bed1b5d4622d 100644 --- a/homeassistant/components/nuki/coordinator.py +++ b/homeassistant/components/nuki/coordinator.py @@ -4,6 +4,7 @@ import asyncio from collections import defaultdict +from dataclasses import dataclass from datetime import timedelta import logging @@ -25,16 +26,28 @@ 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/lock.py b/homeassistant/components/nuki/lock.py index 95c01eac730257..8ff36ba6f919c2 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -12,24 +12,23 @@ 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 46bb165543da7e..0f2a49a8b5ec4c 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -9,23 +9,21 @@ 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/number/const.py b/homeassistant/components/number/const.py index 78ee067bc55edd..f5bdc9b6f928cb 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -60,6 +60,7 @@ ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -168,7 +169,7 @@ class NumberDeviceClass(StrEnum): CURRENT = "current" """Current. - Unit of measurement: `A`, `mA` + Unit of measurement: `A`, `mA`, `μA` """ DATA_RATE = "data_rate" @@ -224,7 +225,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 +630,7 @@ class NumberDeviceClass(StrEnum): 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/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index 5060e6ad0246a4..d24aaeb86209fd 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/const.py b/homeassistant/components/nzbget/const.py index 6742567bbf2d17..cc704e9ae86501 100644 --- a/homeassistant/components/nzbget/const.py +++ b/homeassistant/components/nzbget/const.py @@ -5,10 +5,6 @@ # 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 da3da03b15d3b2..855ff532e72d7a 100644 --- a/homeassistant/components/nzbget/coordinator.py +++ b/homeassistant/components/nzbget/coordinator.py @@ -23,15 +23,18 @@ _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( diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 2328bf453f0367..65d01aebf52649 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -10,15 +10,13 @@ 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 +90,11 @@ 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 ebcdd362b0c17d..0b5464c4f010a6 100644 --- a/homeassistant/components/nzbget/services.py +++ b/homeassistant/components/nzbget/services.py @@ -8,7 +8,6 @@ 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 a4b2dde4c47938..05373345494cd7 100644 --- a/homeassistant/components/nzbget/switch.py +++ b/homeassistant/components/nzbget/switch.py @@ -5,25 +5,21 @@ 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/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 43fd3e3426b0e0..6262661d315e30 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -6,10 +6,12 @@ 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 9cef92d3fce3ee..f1a244fee42165 100644 --- a/homeassistant/components/obihai/button.py +++ b/homeassistant/components/obihai/button.py @@ -7,14 +7,14 @@ 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 +26,12 @@ 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/sensor.py b/homeassistant/components/obihai/sensor.py index ec29238201a2fe..03a11c14001281 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -7,24 +7,24 @@ 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/conditions.yaml b/homeassistant/components/occupancy/conditions.yaml index 1f3cb7346b07f3..98ac1d9a1744d2 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 062dfa8e3369e2..bd33a97b9eb8bb 100644 --- a/homeassistant/components/occupancy/strings.json +++ b/homeassistant/components/occupancy/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_detected": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::occupancy::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::occupancy::common::condition_for_name%]" } }, "name": "Occupancy is detected" @@ -18,26 +23,14 @@ "fields": { "behavior": { "name": "[%key:component::occupancy::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::occupancy::common::condition_for_name%]" } }, "name": "Occupancy is not detected" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Occupancy", "triggers": { "cleared": { @@ -45,6 +38,9 @@ "fields": { "behavior": { "name": "[%key:component::occupancy::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::occupancy::common::trigger_for_name%]" } }, "name": "Occupancy cleared" @@ -54,6 +50,9 @@ "fields": { "behavior": { "name": "[%key:component::occupancy::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::occupancy::common::trigger_for_name%]" } }, "name": "Occupancy detected" diff --git a/homeassistant/components/occupancy/triggers.yaml b/homeassistant/components/occupancy/triggers.yaml index 9613e28c4ce04a..ee355e2d4d9da4 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 a582832d4776e8..a6c45ef26a3055 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -9,7 +9,7 @@ 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 +34,7 @@ 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 +168,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 +206,7 @@ def _async_close_websession(event: Event | None = None) -> None: 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 +230,9 @@ async def async_printer_connect(call: ServiceCall) -> None: 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 +244,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 4d12ef15a4e4b5..deb3059458f4d9 100644 --- a/homeassistant/components/octoprint/binary_sensor.py +++ b/homeassistant/components/octoprint/binary_sensor.py @@ -7,24 +7,20 @@ 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 3a128fcd7aa45f..fe167702745186 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 37347539d5b0a9..118f892ed5b792 100644 --- a/homeassistant/components/octoprint/camera.py +++ b/homeassistant/components/octoprint/camera.py @@ -2,29 +2,25 @@ 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 e20eea0a61f8dd..1d20f7940697de 100644 --- a/homeassistant/components/octoprint/config_flow.py +++ b/homeassistant/components/octoprint/config_flow.py @@ -154,6 +154,8 @@ async def _finish_config(self, user_input: dict[str, Any]) -> ConfigFlowResult: 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 +264,12 @@ async def _async_get_auth_key(self) -> None: 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 +292,6 @@ def _get_octoprint_client(self, user_input: dict[str, Any]) -> OctoprintClient: 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 bb006329ff1198..f37fbc82f5487a 100644 --- a/homeassistant/components/octoprint/coordinator.py +++ b/homeassistant/components/octoprint/coordinator.py @@ -20,19 +20,21 @@ 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 +45,7 @@ def __init__( 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 +53,7 @@ async def _async_update_data(self): """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 +63,7 @@ async def _async_update_data(self): # 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 93fa32a9e33f89..abe27006dfd68b 100644 --- a/homeassistant/components/octoprint/number.py +++ b/homeassistant/components/octoprint/number.py @@ -7,15 +7,14 @@ 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 +36,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 26ef8721d516e8..485126b4828da8 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -12,14 +12,12 @@ 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 +33,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/ohmconnect/__init__.py b/homeassistant/components/ohmconnect/__init__.py index 1713f82a59b358..3d3c9ca34479a3 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/ohme/manifest.json b/homeassistant/components/ohme/manifest.json index 192dede3dbc07b..236492603f29d0 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/omie/__init__.py b/homeassistant/components/omie/__init__.py new file mode 100644 index 00000000000000..a0e1334ff4c938 --- /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 00000000000000..39737c871f30c2 --- /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 00000000000000..f199eeba3d74c5 --- /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 00000000000000..46233819ca9c50 --- /dev/null +++ b/homeassistant/components/omie/coordinator.py @@ -0,0 +1,72 @@ +"""Coordinator for the OMIE - Spain and Portugal electricity prices integration.""" + +from __future__ import annotations + +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 00000000000000..a12fa7c4b9be3c --- /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 00000000000000..29baf4db5146af --- /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 00000000000000..a9d0e5d43cffae --- /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 00000000000000..fe6e2f85e5bce8 --- /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 00000000000000..42cb86574e1acd --- /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 19dffc1a051bf3..89bf1f3b85d3d6 100644 --- a/homeassistant/components/omnilogic/__init__.py +++ b/homeassistant/components/omnilogic/__init__.py @@ -4,27 +4,20 @@ 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 dfbd010ea98f61..cb58d58e72cc09 100644 --- a/homeassistant/components/omnilogic/config_flow.py +++ b/homeassistant/components/omnilogic/config_flow.py @@ -90,6 +90,8 @@ async def async_step_init( 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 24c8cdf2554be7..11bbc6f835d4e2 100644 --- a/homeassistant/components/omnilogic/coordinator.py +++ b/homeassistant/components/omnilogic/coordinator.py @@ -15,17 +15,20 @@ _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 522dcc4f3cd3da..2778abba4645d0 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.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 9583194f41badc..a1a7847eaf24e3 100644 --- a/homeassistant/components/omnilogic/switch.py +++ b/homeassistant/components/omnilogic/switch.py @@ -7,14 +7,13 @@ 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 @@ 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 097cddd66033e1..eae3a4d530b7d5 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -10,7 +10,6 @@ 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 +63,6 @@ async def _async_migrate_func( return old_data -@bind_hass @callback def async_is_onboarded(hass: HomeAssistant) -> bool: """Return if Home Assistant has been onboarded.""" @@ -72,7 +70,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/ondilo_ico/__init__.py b/homeassistant/components/ondilo_ico/__init__.py index 28bb6719c7f245..12a856d7360b8e 100644 --- a/homeassistant/components/ondilo_ico/__init__.py +++ b/homeassistant/components/ondilo_ico/__init__.py @@ -4,7 +4,6 @@ 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 .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 7545f6d61e0c4a..3fbe82a536d8f9 100644 --- a/homeassistant/components/ondilo_ico/coordinator.py +++ b/homeassistant/components/ondilo_ico/coordinator.py @@ -16,8 +16,8 @@ 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 +41,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 42e65bd0db2a0c..61080d2577bec2 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -8,7 +8,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -24,6 +23,7 @@ from .const import DOMAIN from .coordinator import ( + OndiloIcoConfigEntry, OndiloIcoMeasuresCoordinator, OndiloIcoPoolData, OndiloIcoPoolsCoordinator, @@ -78,11 +78,11 @@ 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/services.py b/homeassistant/components/onedrive/services.py index 1e579b82a0fc26..87539a032822fb 100644 --- a/homeassistant/components/onedrive/services.py +++ b/homeassistant/components/onedrive/services.py @@ -42,7 +42,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 +50,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), }, ) diff --git a/homeassistant/components/onedrive/services.yaml b/homeassistant/components/onedrive/services.yaml index 0cf0faf6b60b0d..fdc934ef9b2cbb 100644 --- a/homeassistant/components/onedrive/services.yaml +++ b/homeassistant/components/onedrive/services.yaml @@ -6,9 +6,10 @@ upload: config_entry: integration: onedrive filename: - required: false + required: true selector: - object: + text: + multiple: true destination_folder: required: true selector: diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 4f780b239c949f..1f1aa5ec38924f 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -102,8 +102,8 @@ "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}" }, "no_access_to_path": { "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" @@ -143,24 +143,24 @@ }, "services": { "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 Home Assistant 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/onkyo/coordinator.py b/homeassistant/components/onkyo/coordinator.py index d418b09ad04b83..5c1713e992d412 100644 --- a/homeassistant/components/onkyo/coordinator.py +++ b/homeassistant/components/onkyo/coordinator.py @@ -122,7 +122,7 @@ async def async_send_command( """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/receiver.py b/homeassistant/components/onkyo/receiver.py index f7542e40bee7c9..ed2ec295bfa362 100644 --- a/homeassistant/components/onkyo/receiver.py +++ b/homeassistant/components/onkyo/receiver.py @@ -76,6 +76,8 @@ async def start(self) -> Awaitable[None] | None: if manager_task in done: # Something went wrong, so let's return the manager task, # so that it can be awaited to error out + wait_for_started_task.cancel() + await asyncio.wait((wait_for_started_task,)) return manager_task return None diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 39ffb97e09d72d..1749a0abe4f3ef 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -12,7 +12,6 @@ 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 @@ 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 _cleanup(): # 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 3c740d445d8e40..d4caa6683fb614 100644 --- a/homeassistant/components/onvif/binary_sensor.py +++ b/homeassistant/components/onvif/binary_sensor.py @@ -6,7 +6,6 @@ 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 +13,18 @@ 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 8e92cb07a8ca43..1551e089dfbda3 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 bd5b7db60691fa..e2335b3f2dcfbb 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -17,7 +17,6 @@ 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 +39,6 @@ DIR_LEFT, DIR_RIGHT, DIR_UP, - DOMAIN, GOTOPRESET_MOVE, LOGGER, RELATIVE_MOVE, @@ -49,14 +47,14 @@ 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 +84,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/device.py b/homeassistant/components/onvif/device.py index 47f23da7f99a4f..61c2dafdf336f2 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -41,9 +41,11 @@ TILT_FACTOR, ZOOM_FACTOR, ) -from .event import EventManager +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 +167,7 @@ async def async_setup(self) -> None: # 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 aa2042f3321508..e7e49e8a3bf671 100644 --- a/homeassistant/components/onvif/diagnostics.py +++ b/homeassistant/components/onvif/diagnostics.py @@ -6,21 +6,19 @@ 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/event.py b/homeassistant/components/onvif/event_manager.py similarity index 100% rename from homeassistant/components/onvif/event.py rename to homeassistant/components/onvif/event_manager.py diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index 9d15ca0afe60f5..62b440b1c90073 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -1,7 +1,7 @@ { "domain": "onvif", "name": "ONVIF", - "codeowners": ["@hunterjm", "@jterrace"], + "codeowners": ["@jterrace"], "config_flow": true, "dependencies": ["ffmpeg"], "dhcp": [ diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py index 15e2144b510384..29e323b649ca79 100644 --- a/homeassistant/components/onvif/sensor.py +++ b/homeassistant/components/onvif/sensor.py @@ -6,26 +6,24 @@ 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 d8e1020c6a3cfe..51442cd2acd294 100644 --- a/homeassistant/components/onvif/switch.py +++ b/homeassistant/components/onvif/switch.py @@ -7,12 +7,10 @@ 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 +63,11 @@ class ONVIFSwitchEntityDescription(SwitchEntityDescription): 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/open_router/__init__.py b/homeassistant/components/open_router/__init__.py index 9850f72f71d1d4..57b23c796dbce8 100644 --- a/homeassistant/components/open_router/__init__.py +++ b/homeassistant/components/open_router/__init__.py @@ -10,7 +10,7 @@ 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 +56,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/config_flow.py b/homeassistant/components/open_router/config_flow.py index db9af4c0f26ad8..85ae4ca3744768 100644 --- a/homeassistant/components/open_router/config_flow.py +++ b/homeassistant/components/open_router/config_flow.py @@ -27,6 +27,7 @@ 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 +35,12 @@ 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 +49,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenRouter.""" VERSION = 1 + MINOR_VERSION = 2 @classmethod @callback @@ -66,7 +73,7 @@ async def async_step_user( 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 +81,7 @@ async def async_step_user( 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 +113,7 @@ async def _get_models(self) -> None: class ConversationFlowHandler(OpenRouterSubentryFlowHandler): - """Handle subentry flow.""" + """Handle conversation subentry flow.""" def __init__(self) -> None: """Initialize the subentry flow.""" @@ -208,13 +215,20 @@ async def async_step_init( ): 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 7316d45c3e5f51..1664f98add2b20 100644 --- a/homeassistant/components/open_router/const.py +++ b/homeassistant/components/open_router/const.py @@ -9,9 +9,13 @@ 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 0a2f62f9c94da3..0bf9fd38ec73a7 100644 --- a/homeassistant/components/open_router/entity.py +++ b/homeassistant/components/open_router/entity.py @@ -37,9 +37,8 @@ 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 +51,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 +231,20 @@ async def _async_handle_chat_log( ) -> 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 +300,10 @@ async def _async_handle_chat_log( 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 1a48eb5b44d1bd..5be81a48a75fe6 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 9b71a29dc6b71d..5ac803dfa50832 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 ab99c3cec1d97d..caad20f5d4a85c 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 44fed05e1365d9..edceaf1d6dfbfc 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -46,6 +46,8 @@ 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 +60,8 @@ 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 +212,9 @@ async def send_prompt(call: ServiceCall) -> ServiceResponse: 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 +492,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/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 5843e2f36c8d45..d54c6a7624bae4 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -55,6 +55,7 @@ CONF_REASONING_SUMMARY, CONF_RECOMMENDED, CONF_SERVICE_TIER, + CONF_STORE_RESPONSES, CONF_TEMPERATURE, CONF_TOP_P, CONF_TTS_SPEED, @@ -82,6 +83,7 @@ RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, + RECOMMENDED_STORE_RESPONSES, RECOMMENDED_STT_MODEL, RECOMMENDED_STT_OPTIONS, RECOMMENDED_TEMPERATURE, @@ -125,7 +127,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 +359,10 @@ async def async_step_advanced( 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 +435,37 @@ async def async_step_model( 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 +539,12 @@ async def async_step_model( 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 +593,8 @@ def _get_reasoning_options(self, model: str) -> list[str]: 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 +666,9 @@ async def _get_location_data(self) -> dict[str, str]: "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 2acf2aa9791593..d314e1d4006b25 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -24,6 +24,7 @@ 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_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" diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 50a4f6f8f7e906..bf7d7633917132 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -43,7 +43,10 @@ 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 +78,7 @@ CONF_REASONING_EFFORT, CONF_REASONING_SUMMARY, CONF_SERVICE_TIER, + CONF_STORE_RESPONSES, CONF_TEMPERATURE, CONF_TOP_P, CONF_VERBOSITY, @@ -94,6 +98,7 @@ RECOMMENDED_REASONING_EFFORT, RECOMMENDED_REASONING_SUMMARY, RECOMMENDED_SERVICE_TIER, + RECOMMENDED_STORE_RESPONSES, RECOMMENDED_STT_MODEL, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, @@ -112,7 +117,7 @@ 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 +161,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 +518,24 @@ async def _async_handle_chat_log( 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 +621,13 @@ async def _async_handle_chat_log( 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 +647,8 @@ async def _async_handle_chat_log( 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 18e4c09905de9e..7460bf938a7fb4 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 178910ae0978f1..3193581a3e5d7a 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/tts.py b/homeassistant/components/openai_conversation/tts.py index f3ee614d747060..9aab49a465a518 100644 --- a/homeassistant/components/openai_conversation/tts.py +++ b/homeassistant/components/openai_conversation/tts.py @@ -4,7 +4,7 @@ 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 +166,15 @@ async def async_get_tts_audio( client = self.entry.runtime_data response_format = options[ATTR_PREFERRED_FORMAT] - if response_format not in self._supported_formats: - # common aliases - if response_format == "ogg": - response_format = "opus" - elif response_format == "raw": - response_format = "pcm" - else: - response_format = self.default_options[ATTR_PREFERRED_FORMAT] + if response_format in ("ogg", "oga"): + codec: Literal["mp3", "opus", "aac", "flac", "wav", "pcm"] = "opus" + elif response_format == "raw": + response_format = codec = "pcm" + elif response_format not in self._supported_formats: + response_format = self.default_options[ATTR_PREFERRED_FORMAT] + codec = response_format + else: + codec = response_format try: async with client.audio.speech.with_streaming_response.create( @@ -182,7 +183,7 @@ async def async_get_tts_audio( 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/opendisplay/__init__.py b/homeassistant/components/opendisplay/__init__.py index 53f161a6c70b4e..01e9e7795ba778 100644 --- a/homeassistant/components/opendisplay/__init__.py +++ b/homeassistant/components/opendisplay/__init__.py @@ -8,6 +8,8 @@ from typing import TYPE_CHECKING from opendisplay import ( + AuthenticationFailedError, + AuthenticationRequiredError, BLEConnectionError, BLETimeoutError, GlobalConfig, @@ -17,8 +19,9 @@ 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 +29,21 @@ 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 +53,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 +88,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 +108,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 +123,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 +140,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 +164,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 9dc37489eb8809..4551cfc3b6d912 100644 --- a/homeassistant/components/opendisplay/config_flow.py +++ b/homeassistant/components/opendisplay/config_flow.py @@ -2,11 +2,14 @@ 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 +24,14 @@ 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 +40,16 @@ def __init__(self) -> None: 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 +64,8 @@ async def async_step_bluetooth( 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 +102,11 @@ async def async_step_user( 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 +143,100 @@ async def async_step_user( ), 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 0db0b2f08fde49..664f7e8d306ead 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 00000000000000..9b991f3207045e --- /dev/null +++ b/homeassistant/components/opendisplay/coordinator.py @@ -0,0 +1,90 @@ +"""Passive BLE coordinator for OpenDisplay devices.""" + +from __future__ import annotations + +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/entity.py b/homeassistant/components/opendisplay/entity.py new file mode 100644 index 00000000000000..863fdd7214cb2f --- /dev/null +++ b/homeassistant/components/opendisplay/entity.py @@ -0,0 +1,31 @@ +"""Base entity for OpenDisplay devices.""" + +from __future__ import annotations + +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 00000000000000..9a4bd99626e02a --- /dev/null +++ b/homeassistant/components/opendisplay/event.py @@ -0,0 +1,93 @@ +"""Event platform for OpenDisplay devices — button press/release events.""" + +from __future__ import annotations + +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 f055d425e1cfce..60b850eff51dd6 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 720ec101aac442..6a14ae56adc751 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 00000000000000..2f230ff6c76760 --- /dev/null +++ b/homeassistant/components/opendisplay/sensor.py @@ -0,0 +1,106 @@ +"""Sensor platform for OpenDisplay devices.""" + +from __future__ import annotations + +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 98de6f677f9c34..bbbd129b294cf5 100644 --- a/homeassistant/components/opendisplay/services.py +++ b/homeassistant/components/opendisplay/services.py @@ -12,6 +12,8 @@ import aiohttp from opendisplay import ( + AuthenticationFailedError, + AuthenticationRequiredError, DitherMode, FitMode, OpenDisplayDevice, @@ -38,7 +40,7 @@ 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 +195,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 +225,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 85f1236a60f2bd..92478b3278e99f 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/openexchangerates/__init__.py b/homeassistant/components/openexchangerates/__init__.py index ed704a61fed9eb..4559c098acbf52 100644 --- a/homeassistant/components/openexchangerates/__init__.py +++ b/homeassistant/components/openexchangerates/__init__.py @@ -2,31 +2,28 @@ 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 +45,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/coordinator.py b/homeassistant/components/openexchangerates/coordinator.py index 6245877ddbdde8..295e6f33d72905 100644 --- a/homeassistant/components/openexchangerates/coordinator.py +++ b/homeassistant/components/openexchangerates/coordinator.py @@ -20,16 +20,18 @@ 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 756823ff0ece5e..cb493ab5e849de 100644 --- a/homeassistant/components/openexchangerates/sensor.py +++ b/homeassistant/components/openexchangerates/sensor.py @@ -3,7 +3,6 @@ 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 +10,19 @@ 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 +42,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 f1f080b30f8b10..af494955375b7a 100644 --- a/homeassistant/components/opengarage/__init__.py +++ b/homeassistant/components/opengarage/__init__.py @@ -4,18 +4,17 @@ 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 +26,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 33420ab3fd5a67..d538f261db9c4f 100644 --- a/homeassistant/components/opengarage/binary_sensor.py +++ b/homeassistant/components/opengarage/binary_sensor.py @@ -9,12 +9,10 @@ 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 +28,11 @@ 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 64a4f2f20e7a1c..24920e80e19a9c 100644 --- a/homeassistant/components/opengarage/button.py +++ b/homeassistant/components/opengarage/button.py @@ -13,13 +13,11 @@ 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 +40,11 @@ class OpenGarageButtonEntityDescription(ButtonEntityDescription): 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/coordinator.py b/homeassistant/components/opengarage/coordinator.py index 5d5440d6b1bd4e..f384bd47d26887 100644 --- a/homeassistant/components/opengarage/coordinator.py +++ b/homeassistant/components/opengarage/coordinator.py @@ -18,15 +18,18 @@ _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 859e33827727d7..79b6200aeb1347 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -11,12 +11,10 @@ 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 +24,12 @@ 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/sensor.py b/homeassistant/components/opengarage/sensor.py index 14d14dd5d230e4..cf3625fabb19fa 100644 --- a/homeassistant/components/opengarage/sensor.py +++ b/homeassistant/components/opengarage/sensor.py @@ -11,7 +11,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -22,8 +21,7 @@ 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 +58,11 @@ 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/openhome/__init__.py b/homeassistant/components/openhome/__init__.py index 393f0f4065b884..887b7cc71c1621 100644 --- a/homeassistant/components/openhome/__init__.py +++ b/homeassistant/components/openhome/__init__.py @@ -18,6 +18,8 @@ _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 746468730ef76c..21730c401c4ab5 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -19,11 +19,11 @@ 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 +37,14 @@ 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/update.py b/homeassistant/components/openhome/update.py index cc210866e64648..fc5f4bb2f7a6d7 100644 --- a/homeassistant/components/openhome/update.py +++ b/homeassistant/components/openhome/update.py @@ -13,12 +13,12 @@ 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 +26,14 @@ 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/opensky/__init__.py b/homeassistant/components/opensky/__init__.py index c69cade5842bbd..7cead9fa56db74 100644 --- a/homeassistant/components/opensky/__init__.py +++ b/homeassistant/components/opensky/__init__.py @@ -6,17 +6,16 @@ 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 +33,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 +41,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 5e53a805753dae..0aeed00860882c 100644 --- a/homeassistant/components/opensky/config_flow.py +++ b/homeassistant/components/opensky/config_flow.py @@ -9,12 +9,7 @@ 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 +28,7 @@ DEFAULT_NAME, DOMAIN, ) +from .coordinator import OpenSkyConfigEntry class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): @@ -41,7 +37,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 f9aab88c904ad3..3e0ccbe380ee52 100644 --- a/homeassistant/components/opensky/coordinator.py +++ b/homeassistant/components/opensky/coordinator.py @@ -30,14 +30,16 @@ 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 0ab5b49f086f86..8e34e6581a07aa 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -10,17 +10,17 @@ 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/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 6edb42427f31f1..be6c99b3288771 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -7,7 +7,6 @@ from pyopenuv import Client -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_BINARY_SENSORS, @@ -27,15 +26,18 @@ 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 +80,19 @@ async def async_update_protection_data() -> dict[str, Any]: ] 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 8165c66e7ddef9..30418d8398e95e 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -4,13 +4,12 @@ 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 @@ 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 52e369fd6df02f..5d432d22e39604 100644 --- a/homeassistant/components/openuv/config_flow.py +++ b/homeassistant/components/openuv/config_flow.py @@ -10,7 +10,7 @@ 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 +31,7 @@ DEFAULT_TO_WINDOW, DOMAIN, ) +from .coordinator import OpenUvConfigEntry STEP_REAUTH_SCHEMA = vol.Schema( { @@ -133,7 +134,9 @@ async def _async_verify( @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 b29d272b0ecfe8..7a3dc6cdb3d853 100644 --- a/homeassistant/components/openuv/coordinator.py +++ b/homeassistant/components/openuv/coordinator.py @@ -20,18 +20,20 @@ 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 e16316d4148ec2..005d84f7629364 100644 --- a/homeassistant/components/openuv/diagnostics.py +++ b/homeassistant/components/openuv/diagnostics.py @@ -5,7 +5,6 @@ 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 +13,7 @@ ) 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 +29,10 @@ 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/sensor.py b/homeassistant/components/openuv/sensor.py index 5b681655e2b166..7fdeaeb382ba59 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -12,7 +12,6 @@ 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 +19,6 @@ from .const import ( DATA_UV, - DOMAIN, TYPE_CURRENT_OZONE_LEVEL, TYPE_CURRENT_UV_INDEX, TYPE_CURRENT_UV_LEVEL, @@ -32,7 +30,7 @@ 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 +165,11 @@ class OpenUvSensorEntityDescription(SensorEntityDescription): 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/opnsense/__init__.py b/homeassistant/components/opnsense/__init__.py index bc085dbfa4d92e..822851aca746a6 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 @@ ) -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 5f6d8d2d43638f..259a6394e69c57 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 @@ def _get_mac_addrs(self, devices: list[DeviceDetails]) -> DeviceDetailsByMAC | d 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 @@ def get_device_name(self, device: str) -> str | None: 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 0a9aecbde2560c..b2d57e017c2eb9 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/manifest.json b/homeassistant/components/opower/manifest.json index b28ba65606cf93..f8c8490b5086cc 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.1"] + "requirements": ["opower==0.18.2"] } diff --git a/homeassistant/components/opower/quality_scale.yaml b/homeassistant/components/opower/quality_scale.yaml index c51fa99c8fff10..2df7bc5764dcb3 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/opple/__init__.py b/homeassistant/components/opple/__init__.py index 41ef2b0fdd8de4..17b462d3ac3604 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/osoenergy/__init__.py b/homeassistant/components/osoenergy/__init__.py index ca6d52941f794a..d4e4c881d8d553 100644 --- a/homeassistant/components/osoenergy/__init__.py +++ b/homeassistant/components/osoenergy/__init__.py @@ -12,7 +12,7 @@ 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 @@ } -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 a2ba61ccbe4977..7ec6308e20930e 100644 --- a/homeassistant/components/osoenergy/binary_sensor.py +++ b/homeassistant/components/osoenergy/binary_sensor.py @@ -10,11 +10,10 @@ 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 @@ class OSOEnergyBinarySensorEntityDescription(BinarySensorEntityDescription): 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 c2b1e75cd702e4..1b66f21e105e21 100644 --- a/homeassistant/components/osoenergy/sensor.py +++ b/homeassistant/components/osoenergy/sensor.py @@ -12,7 +12,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfEnergy, UnitOfPower, @@ -23,7 +22,7 @@ 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 @@ class OSOEnergySensorEntityDescription(SensorEntityDescription): 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 1f4ad9d06c52e7..e38e502fc76bbd 100644 --- a/homeassistant/components/osoenergy/water_heater.py +++ b/homeassistant/components/osoenergy/water_heater.py @@ -15,7 +15,6 @@ 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.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 @@ 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/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index 0a33ca835e4e7e..1f10ce2456d2b8 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/otp/manifest.json b/homeassistant/components/otp/manifest.json index f6adbb20427efa..74c1c9eb26495c 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/strings.json b/homeassistant/components/otp/strings.json index af811a8ab2c5ca..1e654bbd5a73d6 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 a83430b3531889..8f604257685132 100644 --- a/homeassistant/components/ourgroceries/__init__.py +++ b/homeassistant/components/ourgroceries/__init__.py @@ -6,21 +6,19 @@ 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 +30,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/coordinator.py b/homeassistant/components/ourgroceries/coordinator.py index a822931e88c0f7..be9149e0162a74 100644 --- a/homeassistant/components/ourgroceries/coordinator.py +++ b/homeassistant/components/ourgroceries/coordinator.py @@ -19,13 +19,19 @@ _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 f257ef481c7481..eea7952eb4f883 100644 --- a/homeassistant/components/ourgroceries/todo.py +++ b/homeassistant/components/ourgroceries/todo.py @@ -9,22 +9,20 @@ 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/cover.py b/homeassistant/components/overkiz/cover.py new file mode 100644 index 00000000000000..c6a7bebd805a8e --- /dev/null +++ b/homeassistant/components/overkiz/cover.py @@ -0,0 +1,641 @@ +"""Support for Overkiz covers - shutters etc.""" + +from __future__ import annotations + +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.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OverkizDataConfigEntry +from .const import 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_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 dd3216f9c1095e..00000000000000 --- 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 4b6e5b176a7577..00000000000000 --- 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 df13072524d064..00000000000000 --- 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 48ac2c838c5356..00000000000000 --- 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/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py index 436180407f49ad..b496f7ca92f946 100644 --- a/homeassistant/components/ovo_energy/__init__.py +++ b/homeassistant/components/ovo_energy/__init__.py @@ -2,30 +2,25 @@ 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 +42,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 - - 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, - } + coordinator = OVOEnergyDataUpdateCoordinator(hass, entry, client) - # 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/const.py b/homeassistant/components/ovo_energy/const.py index 2d615e7c44a495..e1cf957b992713 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 00000000000000..7b41de0b33841a --- /dev/null +++ b/homeassistant/components/ovo_energy/coordinator.py @@ -0,0 +1,63 @@ +"""Coordinator for the OVO Energy integration.""" + +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 +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 ed8a24b05425a3..d3efc151b59644 100644 --- a/homeassistant/components/ovo_energy/entity.py +++ b/homeassistant/components/ovo_energy/entity.py @@ -2,32 +2,18 @@ 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 +23,7 @@ def device_info(self) -> DeviceInfo: """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 e2ac9410cbc4fc..32e7e5743f0bc2 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -7,7 +7,6 @@ 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 +15,14 @@ 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 +112,11 @@ class OVOEnergySensorEntityDescription(SensorEntityDescription): 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 +134,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 +148,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 +156,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 623e5e17b66d1b..0e07a1c1cec7f1 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/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 22762cb390dd5d..691f7789f37ce3 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 diff --git a/homeassistant/components/palazzetti/quality_scale.yaml b/homeassistant/components/palazzetti/quality_scale.yaml index ff8461ad1938ac..d4ef278705cfe7 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 a39b070b3c5f25..53c222ffc1f5d0 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 0a5e5d24b682fd..0547e5f1b235bd 100644 --- a/homeassistant/components/panasonic_bluray/media_player.py +++ b/homeassistant/components/panasonic_bluray/media_player.py @@ -39,7 +39,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 +59,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 2d0a2b9d26ba81..1478b02095ed54 100644 --- a/homeassistant/components/panasonic_viera/__init__.py +++ b/homeassistant/components/panasonic_viera/__init__.py @@ -19,7 +19,6 @@ from .const import ( ATTR_DEVICE_INFO, - ATTR_REMOTE, ATTR_UDN, CONF_APP_ID, CONF_ENCRYPTION_KEY, @@ -29,6 +28,8 @@ 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 b00fee513a63e7..58baccf0dcc7aa 100644 --- a/homeassistant/components/panasonic_viera/config_flow.py +++ b/homeassistant/components/panasonic_viera/config_flow.py @@ -168,5 +168,7 @@ async def async_load_data(self, config: dict[str, Any]) -> None: 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 a78920f33a5182..b2c5bdd1a5db94 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -17,17 +17,16 @@ 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 +38,14 @@ 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 5fa4be9ca2b906..59090e46ef72f9 100644 --- a/homeassistant/components/panasonic_viera/remote.py +++ b/homeassistant/components/panasonic_viera/remote.py @@ -6,18 +6,16 @@ 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 +25,14 @@ 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 db9c35a7608182..ec6063d4fdab8d 100644 --- a/homeassistant/components/panel_custom/__init__.py +++ b/homeassistant/components/panel_custom/__init__.py @@ -10,7 +10,6 @@ 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 +70,6 @@ ) -@bind_hass async def async_register_panel( hass: HomeAssistant, # The url to serve the panel diff --git a/homeassistant/components/peblar/config_flow.py b/homeassistant/components/peblar/config_flow.py index b9b42cd6ca52f0..786e56c0a208ff 100644 --- a/homeassistant/components/peblar/config_flow.py +++ b/homeassistant/components/peblar/config_flow.py @@ -165,6 +165,8 @@ async def async_step_zeroconf_confirm( 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/manifest.json b/homeassistant/components/peblar/manifest.json index fdb2e7ad7d82db..ce1f281cafea40 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/quality_scale.yaml b/homeassistant/components/peblar/quality_scale.yaml index 91f9bb7af55645..a67344cf7b41e0 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/peco/__init__.py b/homeassistant/components/peco/__init__.py index 9dd32ecf14c093..e36de2d6fa9dd2 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -4,37 +4,39 @@ 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 86ec12a399987e..3b80cc81ab1170 100644 --- a/homeassistant/components/peco/binary_sensor.py +++ b/homeassistant/components/peco/binary_sensor.py @@ -8,28 +8,23 @@ 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/coordinator.py b/homeassistant/components/peco/coordinator.py index 0ecc6d23ef22b6..9c42cddc5dd050 100644 --- a/homeassistant/components/peco/coordinator.py +++ b/homeassistant/components/peco/coordinator.py @@ -1,5 +1,7 @@ """DataUpdateCoordinator for the PECO Outage Counter integration.""" +from __future__ import annotations + from dataclasses import dataclass from datetime import timedelta @@ -28,12 +30,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 +78,10 @@ async def _async_update_data(self) -> 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 a376fa8fc5aace..b7e0b5e733a074 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -11,7 +11,6 @@ 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 +18,7 @@ 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 +71,12 @@ class PECOSensorEntityDescription(SensorEntityDescription): 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/permobil/__init__.py b/homeassistant/components/permobil/__init__.py index 441c6a2646e181..683dd6aa7ea66c 100644 --- a/homeassistant/components/permobil/__init__.py +++ b/homeassistant/components/permobil/__init__.py @@ -6,7 +6,6 @@ from mypermobil import MyPermobil, MyPermobilClientException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CODE, CONF_EMAIL, @@ -19,15 +18,15 @@ 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 +50,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 c2d51067e19a6b..2d167d2952496b 100644 --- a/homeassistant/components/permobil/binary_sensor.py +++ b/homeassistant/components/permobil/binary_sensor.py @@ -8,7 +8,6 @@ from mypermobil import BATTERY_CHARGING -from homeassistant import config_entries from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, @@ -16,8 +15,7 @@ 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 +39,12 @@ class PermobilBinarySensorEntityDescription(BinarySensorEntityDescription): 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/coordinator.py b/homeassistant/components/permobil/coordinator.py index ea7ddadff9fb5f..16a5d93751d641 100644 --- a/homeassistant/components/permobil/coordinator.py +++ b/homeassistant/components/permobil/coordinator.py @@ -13,6 +13,8 @@ _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 8445bf8b4462ab..fc58407a5f97f9 100644 --- a/homeassistant/components/permobil/sensor.py +++ b/homeassistant/components/permobil/sensor.py @@ -23,7 +23,6 @@ USAGE_DISTANCE, ) -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -34,8 +33,8 @@ 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 +175,12 @@ class PermobilSensorEntityDescription(SensorEntityDescription): 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 2871f4b575ad17..05eed017db85de 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -19,7 +19,6 @@ 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 +74,6 @@ def async_register_callback( ) -@bind_hass def create( hass: HomeAssistant, message: str, @@ -86,14 +84,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 +123,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 e2271dd7bf6a8b..c6e98b48447fd0 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/person/__init__.py b/homeassistant/components/person/__init__.py index d67f45d1540baf..e7bbef67e3267f 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -11,6 +11,7 @@ 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 +52,6 @@ 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 +92,6 @@ ) -@bind_hass async def async_create_person( hass: HomeAssistant, name: str, @@ -110,7 +109,6 @@ async def async_create_person( ) -@bind_hass async def async_add_user_device_tracker( hass: HomeAssistant, user_id: str, device_tracker_entity_id: str ) -> None: @@ -435,6 +433,7 @@ def __init__(self, config: dict[str, Any]) -> None: 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 +551,7 @@ def _update_state(self) -> None: 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 +566,8 @@ def _parse_source_state(self, state: State, coordinates: State) -> None: 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 +576,7 @@ def _update_extra_state_attributes(self) -> None: 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 3e5e9c1aa52a11..00000000000000 --- 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 cd1d80aba38686..f645d9c20905fc 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/strings.json b/homeassistant/components/person/strings.json index af211e373a7e61..385bad47cb489d 100644 --- a/homeassistant/components/person/strings.json +++ b/homeassistant/components/person/strings.json @@ -1,28 +1,4 @@ { - "common": { - "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" - }, - "conditions": { - "is_home": { - "description": "Tests if one or more persons are home.", - "fields": { - "behavior": { - "name": "[%key:component::person::common::condition_behavior_name%]" - } - }, - "name": "Person is home" - }, - "is_not_home": { - "description": "Tests if one or more persons are not home.", - "fields": { - "behavior": { - "name": "[%key:component::person::common::condition_behavior_name%]" - } - }, - "name": "Person is not home" - } - }, "entity_component": { "_": { "name": "[%key:component::person::title%]", @@ -49,46 +25,11 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "reload": { "description": "Reloads persons from the YAML-configuration.", "name": "Reload persons" } }, - "title": "Person", - "triggers": { - "entered_home": { - "description": "Triggers when one or more persons enter home.", - "fields": { - "behavior": { - "name": "[%key:component::person::common::trigger_behavior_name%]" - } - }, - "name": "Entered home" - }, - "left_home": { - "description": "Triggers when one or more persons leave home.", - "fields": { - "behavior": { - "name": "[%key:component::person::common::trigger_behavior_name%]" - } - }, - "name": "Left home" - } - } + "title": "Person" } diff --git a/homeassistant/components/person/trigger.py b/homeassistant/components/person/trigger.py deleted file mode 100644 index 0ca46a6cd431ca..00000000000000 --- 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 31208321b54d51..00000000000000 --- 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/picnic/__init__.py b/homeassistant/components/picnic/__init__.py index bf9bb61b539a14..9bd76865c7137f 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 a60086173a8424..118d752dd9eca4 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -7,7 +7,11 @@ 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 +22,19 @@ 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 +45,23 @@ } ) +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", + ) + ), + } +) -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_SCHEMA = vol.Schema( + { + vol.Required(CONF_2FA_CODE): str, } +) class PicnicConfigFlow(ConfigFlow, domain=DOMAIN): @@ -81,6 +69,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 +83,7 @@ async def async_step_reauth( 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 +92,122 @@ async def async_step_user( 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 f8737806746651..8996e5b971b6fc 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -4,9 +4,6 @@ DOMAIN = "picnic" -CONF_API = "api" -CONF_COORDINATOR = "coordinator" - SERVICE_ADD_PRODUCT_TO_CART = "add_product" ATTR_PRODUCT_ID = "product_id" @@ -15,6 +12,7 @@ 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 a63be7614c2550..55827ee1e843bb 100644 --- a/homeassistant/components/picnic/coordinator.py +++ b/homeassistant/components/picnic/coordinator.py @@ -1,5 +1,7 @@ """Coordinator to fetch data from the Picnic API.""" +from __future__ import annotations + import asyncio from contextlib import suppress import copy @@ -17,17 +19,19 @@ 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 +49,6 @@ def __init__( 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 +58,10 @@ async def _async_update_data(self) -> dict: 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/sensor.py b/homeassistant/components/picnic/sensor.py index dcfd908649193c..a6b1f3ae8c4ef1 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -12,7 +12,6 @@ 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 +22,6 @@ from .const import ( ATTRIBUTION, - CONF_COORDINATOR, DOMAIN, SENSOR_CART_ITEMS_COUNT, SENSOR_CART_TOTAL_PRICE, @@ -42,7 +40,7 @@ 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 +200,11 @@ class PicnicSensorEntityDescription(SensorEntityDescription): 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 +223,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 d0465fcc13c436..bdc3395020450f 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -7,6 +7,7 @@ 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 +17,10 @@ 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 +51,14 @@ async def async_add_product_service(call: ServiceCall): 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 db56d032b1d279..e2cea9b4d4d2a1 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 383c236de3c17d..aee818a8fe615c 100644 --- a/homeassistant/components/picnic/todo.py +++ b/homeassistant/components/picnic/todo.py @@ -11,15 +11,14 @@ 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 +26,11 @@ 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 +45,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 7ffc80db2f95f2..c8e47e7f22a2c5 100644 --- a/homeassistant/components/picotts/__init__.py +++ b/homeassistant/components/picotts/__init__.py @@ -1 +1,31 @@ -"""Support for pico integration.""" +"""The Pico TTS integration.""" + +from __future__ import annotations + +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 00000000000000..eb9684bdcb871e --- /dev/null +++ b/homeassistant/components/picotts/config_flow.py @@ -0,0 +1,49 @@ +"""Config flow for Pico TTS integration.""" + +from __future__ import annotations + +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 00000000000000..055577ee7eff04 --- /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 00000000000000..c932a5b8ff4c90 --- /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 6e8c346a3c930c..20ca5487e9457f 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 00000000000000..fbe6183180944d --- /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 11cb2d7f557eb3..90ae5847b7ce54 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 @@ 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 -_LOGGER = logging.getLogger(__name__) - -SUPPORT_LANGUAGES = ["en-US", "en-GB", "de-DE", "es-ES", "fr-FR", "it-IT"] +from .const import DEFAULT_LANG, DOMAIN, SUPPORT_LANGUAGES +from .issue import deprecate_yaml_issue -DEFAULT_LANG = "en-US" +_LOGGER = logging.getLogger(__name__) 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 @@ def get_tts_audio( _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/manifest.json b/homeassistant/components/pilight/manifest.json index da07c4ee645c0d..e2764026dd8dda 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/ping/__init__.py b/homeassistant/components/ping/__init__.py index 1383e4c035a4f8..49a7a0c6e3e6cc 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -71,6 +71,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/pjlink/__init__.py b/homeassistant/components/pjlink/__init__.py index ab4d7fd377dd52..79a1f8f76fbfdf 100644 --- a/homeassistant/components/pjlink/__init__.py +++ b/homeassistant/components/pjlink/__init__.py @@ -1 +1,22 @@ -"""The pjlink component.""" +"""The PJLink integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +_PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up PJLink 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/pjlink/config_flow.py b/homeassistant/components/pjlink/config_flow.py new file mode 100644 index 00000000000000..c2cf722e598c68 --- /dev/null +++ b/homeassistant/components/pjlink/config_flow.py @@ -0,0 +1,100 @@ +"""Config flow for the PJLink integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pypjlink import Projector +from pypjlink.projector import ProjectorError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT +from homeassistant.helpers import config_validation as cv + +from .const import DEFAULT_PORT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PASSWORD): str, + } +) + + +def validate_projector_connection( + host: str, port: int | None, password: str | None +) -> str: + """Validate that we can connect to the projector.""" + with Projector.from_address(host, port) as projector: + projector.authenticate(password) + return projector.get_name() + + +class PJLinkConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for PJLink.""" + + 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: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) + try: + projector_name = await self.hass.async_add_executor_job( + validate_projector_connection, + user_input[CONF_HOST], + user_input[CONF_PORT], + user_input.get(CONF_PASSWORD), + ) + except TimeoutError, OSError: + errors["base"] = "cannot_connect" + except RuntimeError, ProjectorError: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=projector_name, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import( + self, import_config: dict[str, Any] + ) -> ConfigFlowResult: + """Import a config entry from configuration.yaml.""" + + host: str = import_config[CONF_HOST] + port: int = import_config.get(CONF_PORT, DEFAULT_PORT) + password: str | None = import_config.get(CONF_PASSWORD) + + self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port}) + try: + projector_name = await self.hass.async_add_executor_job( + validate_projector_connection, host, port, password + ) + except TimeoutError, OSError: + return self.async_abort(reason="cannot_connect") + except RuntimeError, ProjectorError: + return self.async_abort(reason="invalid_auth") + except Exception: + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason="unknown") + else: + import_data: dict[str, Any] = {CONF_HOST: host, CONF_PORT: port} + if password: + import_data[CONF_PASSWORD] = password + return self.async_create_entry( + title=import_config.get(CONF_NAME, projector_name), data=import_data + ) diff --git a/homeassistant/components/pjlink/manifest.json b/homeassistant/components/pjlink/manifest.json index 787311b250a581..6b213592189ac2 100644 --- a/homeassistant/components/pjlink/manifest.json +++ b/homeassistant/components/pjlink/manifest.json @@ -2,9 +2,10 @@ "domain": "pjlink", "name": "PJLink", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/pjlink", + "integration_type": "device", "iot_class": "local_polling", "loggers": ["pypjlink"], - "quality_scale": "legacy", "requirements": ["pypjlink2==1.2.1"] } diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py index 1e035205f8f5a8..dea2f801db9066 100644 --- a/homeassistant/components/pjlink/media_player.py +++ b/homeassistant/components/pjlink/media_player.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from pypjlink import MUTE_AUDIO, Projector from pypjlink.projector import ProjectorError import voluptuous as vol @@ -12,10 +14,15 @@ MediaPlayerEntityFeature, MediaPlayerState, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, + AddEntitiesCallback, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_ENCODING, DEFAULT_ENCODING, DEFAULT_PORT, DOMAIN @@ -33,30 +40,60 @@ ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the PJLink platform.""" - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - name = config.get(CONF_NAME) - encoding = config.get(CONF_ENCODING) - password = config.get(CONF_PASSWORD) - - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - hass_data = hass.data[DOMAIN] - - device_label = f"{host}:{port}" - if device_label in hass_data: + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + ir.async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{result.get('reason')}", + breaks_in_ha_version="2026.11.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "PJLink", + }, + ) return - device = PjLinkDevice(host, port, name, encoding, password) - hass_data[device_label] = device - add_entities([device], True) + 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": "PJLink", + }, + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up PJLink media player.""" + async_add_entities([PjLinkDevice(entry)], update_before_add=True) def format_input_source(input_source_name, input_source_number): @@ -74,20 +111,20 @@ class PjLinkDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, host, port, name, encoding, password): - """Iinitialize the PJLink device.""" - self._host = host - self._port = port - self._password = password - self._encoding = encoding - self._source_name_mapping = {} + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the PJLink device.""" + self._host = entry.data[CONF_HOST] + self._port = entry.data[CONF_PORT] + self._password = entry.data.get(CONF_PASSWORD) + self._source_name_mapping: dict[str, Any] = {} - self._attr_name = name + self._attr_name = entry.title self._attr_is_volume_muted = False self._attr_state = MediaPlayerState.OFF self._attr_source = None self._attr_source_list = [] self._attr_available = False + self._attr_unique_id = entry.entry_id def _force_off(self): self._attr_state = MediaPlayerState.OFF diff --git a/homeassistant/components/pjlink/strings.json b/homeassistant/components/pjlink/strings.json new file mode 100644 index 00000000000000..c151f16b4cddfe --- /dev/null +++ b/homeassistant/components/pjlink/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]" + } + } + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]" + }, + "deprecated_yaml_import_issue_invalid_auth": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, invalid authentication details were found. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]" + }, + "deprecated_yaml_import_issue_unknown": { + "description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, an unexpected exception occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.", + "title": "[%key:component::homeassistant::issues::deprecated_yaml::title%]" + } + } +} diff --git a/homeassistant/components/plaato/__init__.py b/homeassistant/components/plaato/__init__.py index 490bc094aaaca8..68f68ad90b7d1b 100644 --- a/homeassistant/components/plaato/__init__.py +++ b/homeassistant/components/plaato/__init__.py @@ -23,7 +23,6 @@ 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 @@ 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 @@ ) -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) - - -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 + coordinator = entry.runtime_data.coordinator + return await hass.config_entries.async_unload_platforms( + entry, coordinator.platforms if coordinator else PLATFORMS + ) -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 de574738d8d9b1..dc7203490acb82 100644 --- a/homeassistant/components/plaato/binary_sensor.py +++ b/homeassistant/components/plaato/binary_sensor.py @@ -8,17 +8,17 @@ 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 +26,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 +42,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 ee345563cd670d..96f434d466cfb9 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -210,6 +210,8 @@ async def async_step_user( 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 73382765bfe765..33ef69b8c458ab 100644 --- a/homeassistant/components/plaato/const.py +++ b/homeassistant/components/plaato/const.py @@ -19,13 +19,7 @@ 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 74ff8566729c15..22b64d9a310349 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 @@ _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 @@ def __init__( 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 9cc63a38a64954..31a3654ca21cec 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.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 @@ def _sensor_name(self) -> str: 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 7a98c8a1cedef2..b4a830b9cdcf67 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -6,7 +6,6 @@ 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 +18,8 @@ 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 +34,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 +60,8 @@ def _async_update_from_webhook(device_id, sensor_data: PlaatoDevice): 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 +71,23 @@ def _async_update_from_webhook(device_id, sensor_data: PlaatoDevice): 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/plex/helpers.py b/homeassistant/components/plex/helpers.py index 3c7ff8180c8cc8..341534e8ad7531 100644 --- a/homeassistant/components/plex/helpers.py +++ b/homeassistant/components/plex/helpers.py @@ -28,6 +28,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/pooldose/strings.json b/homeassistant/components/pooldose/strings.json index 67656c9d6e1718..e8e8a5ea416b81 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/portainer/binary_sensor.py b/homeassistant/components/portainer/binary_sensor.py index 787656b0268047..201b07c1c21e46 100644 --- a/homeassistant/components/portainer/binary_sensor.py +++ b/homeassistant/components/portainer/binary_sensor.py @@ -5,6 +5,8 @@ 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 +17,6 @@ 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 +54,7 @@ class PortainerStackBinarySensorEntityDescription(BinarySensorEntityDescription) 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 daa17452379736..d9506b8395690f 100644 --- a/homeassistant/components/portainer/button.py +++ b/homeassistant/components/portainer/button.py @@ -14,6 +14,7 @@ PortainerConnectionError, PortainerTimeoutError, ) +from pyportainer.models.docker import DockerContainer from homeassistant.components.button import ( ButtonDeviceClass, @@ -41,10 +42,9 @@ 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 +60,14 @@ class PortainerButtonDescription(ButtonEntityDescription): ) ), ), + 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 +102,29 @@ class PortainerButtonDescription(ButtonEntityDescription): ) ), ), + 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 +212,8 @@ async def async_press(self) -> None: 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/const.py b/homeassistant/components/portainer/const.py index 8c1f1fa9d094a1..ae8e8ee98dffd7 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 73f1577de12d8c..cba2c6ef0a02ba 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -9,6 +9,8 @@ import logging from pyportainer import ( + DockerContainerState, + EndpointStatus, Portainer, PortainerAuthenticationError, PortainerConnectionError, @@ -18,6 +20,8 @@ DockerContainer, DockerContainerStats, DockerSystemDF, + DockerVolume, + DockerVolumeUsageData, ) from pyportainer.models.docker_inspect import DockerInfo, DockerVersion from pyportainer.models.portainer import Endpoint @@ -26,10 +30,10 @@ 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 +54,7 @@ class PortainerCoordinatorData: docker_info: DockerInfo docker_system_df: DockerSystemDF stacks: dict[str, PortainerStackData] + volumes: dict[str, PortainerVolumeData] @dataclass(slots=True) @@ -70,6 +75,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 +106,7 @@ def __init__( 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 +119,9 @@ def __init__( 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 +134,13 @@ async def _async_setup(self) -> None: 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,11 +184,13 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: docker_version, docker_info, docker_system_df, + volumes, ) = await asyncio.gather( self.portainer.get_containers(endpoint.id), self.portainer.docker_version(endpoint.id), self.portainer.docker_info(endpoint.id), - self.portainer.docker_system_df(endpoint.id), + 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)] @@ -203,6 +221,19 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: 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]) @@ -231,18 +262,19 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: 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( *( @@ -250,7 +282,7 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: endpoint_id=endpoint.id, container_id=container.id, ) - for container in running_containers + for container in active_containers ) ), strict=False, @@ -283,6 +315,7 @@ async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: docker_version=docker_version, docker_info=docker_info, docker_system_df=docker_system_df, + volumes=volume_map, stacks=stack_map, ) @@ -329,6 +362,28 @@ def _async_add_remove_endpoints( 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/entity.py b/homeassistant/components/portainer/entity.py index 9fb87248e633dc..04b61aaa3c4a4f 100644 --- a/homeassistant/components/portainer/entity.py +++ b/homeassistant/components/portainer/entity.py @@ -9,10 +9,12 @@ from .const import DEFAULT_NAME, DOMAIN from .coordinator import ( + DockerVolume, PortainerContainerData, PortainerCoordinator, PortainerCoordinatorData, PortainerStackData, + PortainerVolumeData, ) @@ -173,3 +175,56 @@ def available(self) -> bool: 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 319efef85dc061..842cdc16fc23a4 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 ecbbd05e4dcfa7..f60fe1e30700d6 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 503c6e1093ec56..c67418d2504082 100644 --- a/homeassistant/components/portainer/sensor.py +++ b/homeassistant/components/portainer/sensor.py @@ -5,6 +5,8 @@ from collections.abc import Callable from dataclasses import dataclass +from pyportainer import StackType + from homeassistant.components.sensor import ( EntityCategory, SensorDeviceClass, @@ -17,17 +19,18 @@ 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 +57,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 +296,6 @@ class PortainerStackSensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, ), ) - STACK_SENSORS: tuple[PortainerStackSensorEntityDescription, ...] = ( PortainerStackSensorEntityDescription( key="stack_type", @@ -312,6 +321,25 @@ class PortainerStackSensorEntityDescription(SensorEntityDescription): 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 +393,25 @@ def _async_add_new_stacks( 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 +434,13 @@ def _async_add_new_stacks( 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 +475,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 e50d48849dbe93..c0fffcf504b1a9 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": { diff --git a/homeassistant/components/portainer/switch.py b/homeassistant/components/portainer/switch.py index 478c991f513a26..2b162abe98ceac 100644 --- a/homeassistant/components/portainer/switch.py +++ b/homeassistant/components/portainer/switch.py @@ -6,7 +6,7 @@ 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 +23,7 @@ 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 +88,10 @@ async def _perform_action( 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/power/conditions.yaml b/homeassistant/components/power/conditions.yaml index 63f2c82b20f805..1776a1a46d496d 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 9be4af702e59af..18d724d67cd9ad 100644 --- a/homeassistant/components/power/strings.json +++ b/homeassistant/components/power/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -12,6 +14,9 @@ "behavior": { "name": "[%key:component::power::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::power::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::power::common::condition_threshold_name%]" } @@ -19,21 +24,6 @@ "name": "Power value" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Power", "triggers": { "changed": { @@ -51,6 +41,9 @@ "behavior": { "name": "[%key:component::power::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::power::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::power::common::trigger_threshold_name%]" } diff --git a/homeassistant/components/power/triggers.yaml b/homeassistant/components/power/triggers.yaml index 22dac96db363df..95d8cc19ee5fc1 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/config_flow.py b/homeassistant/components/powerfox/config_flow.py index dd17badf88181e..95aa09defa313c 100644 --- a/homeassistant/components/powerfox/config_flow.py +++ b/homeassistant/components/powerfox/config_flow.py @@ -8,7 +8,11 @@ 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 +42,27 @@ async def async_step_user( 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 +83,17 @@ async def async_step_reauth_confirm( 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 +104,22 @@ async def async_step_reauth_confirm( async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Reconfigure Powerfox configuration.""" - errors = {} - - 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, + """Handle reconfiguration.""" + return await self.async_step_user() + + 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/strings.json b/homeassistant/components/powerfox/strings.json index 6b98677cf19260..cb3598e0a41702 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/private_ble_device/coordinator.py b/homeassistant/components/private_ble_device/coordinator.py index 3e7bafed748d48..0b8529e41c9793 100644 --- a/homeassistant/components/private_ble_device/coordinator.py +++ b/homeassistant/components/private_ble_device/coordinator.py @@ -242,6 +242,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/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 66b35eaff210e1..a4e23edd8d1465 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 b95f6d83738d10..8a7c73b368d690 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 4d090f4d0c1897..32dbb5bd41d66d 100644 --- a/homeassistant/components/progettihwsw/__init__.py +++ b/homeassistant/components/progettihwsw/__init__.py @@ -8,33 +8,32 @@ 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 aeec792cff1b7b..26643065994fc7 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -7,7 +7,6 @@ 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 @@ 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 b2f00d52439ca9..06a8f6d0d662f3 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -8,7 +8,6 @@ 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 @@ 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/prosegur/__init__.py b/homeassistant/components/prosegur/__init__.py index bf2aad451df06f..1fb89f3b370308 100644 --- a/homeassistant/components/prosegur/__init__.py +++ b/homeassistant/components/prosegur/__init__.py @@ -10,25 +10,24 @@ 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 1f0f89c5f04eb0..335737b40edd47 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -12,12 +12,12 @@ 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 +31,12 @@ 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 3e1c91713e1b10..59bae6f71f01ab 100644 --- a/homeassistant/components/prosegur/camera.py +++ b/homeassistant/components/prosegur/camera.py @@ -9,7 +9,6 @@ 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 +16,15 @@ 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 +37,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 ec13f5511a461e..944a84c3acbb3f 100644 --- a/homeassistant/components/prosegur/diagnostics.py +++ b/homeassistant/components/prosegur/diagnostics.py @@ -7,24 +7,24 @@ 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/manifest.json b/homeassistant/components/prowl/manifest.json index deac43d16574f4..9c31ce7965e2aa 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/proxmoxve/button.py b/homeassistant/components/proxmoxve/button.py index 3af7401c36ad26..8098152fbb5a7d 100644 --- a/homeassistant/components/proxmoxve/button.py +++ b/homeassistant/components/proxmoxve/button.py @@ -146,6 +146,14 @@ class ProxmoxContainerButtonEntityDescription(ButtonEntityDescription): ), 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", diff --git a/homeassistant/components/proxmoxve/config_flow.py b/homeassistant/components/proxmoxve/config_flow.py index 7845f5405b920e..8b8fb917a7ad98 100644 --- a/homeassistant/components/proxmoxve/config_flow.py +++ b/homeassistant/components/proxmoxve/config_flow.py @@ -97,6 +97,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,6 +118,7 @@ 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 @@ -115,7 +129,10 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]: 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 +315,15 @@ async def _validate_input( 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 +393,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/coordinator.py b/homeassistant/components/proxmoxve/coordinator.py index 6231a989a215c7..a15b7c897f19ba 100644 --- a/homeassistant/components/proxmoxve/coordinator.py +++ b/homeassistant/components/proxmoxve/coordinator.py @@ -22,11 +22,7 @@ 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 +43,16 @@ _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 +118,13 @@ async def _async_setup(self) -> None: 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 +150,7 @@ async def _async_update_data(self) -> 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 +182,16 @@ async def _async_update_data(self) -> 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 +236,22 @@ def _init_proxmox(self) -> None: 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 +261,9 @@ def _get_node_data( 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/icons.json b/homeassistant/components/proxmoxve/icons.json index 9f1a83a98cc3b0..7c60fef838f6f0 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/strings.json b/homeassistant/components/proxmoxve/strings.json index 56c9a79781a632..a92e6ef4506fe3 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,6 +142,9 @@ "reset": { "name": "Reset" }, + "resume_all": { + "name": "Resume" + }, "shutdown": { "name": "Shut down" }, @@ -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/prusalink/__init__.py b/homeassistant/components/prusalink/__init__.py index 4bb7dee411d355..d181502acccd05 100644 --- a/homeassistant/components/prusalink/__init__.py +++ b/homeassistant/components/prusalink/__init__.py @@ -24,6 +24,7 @@ InfoUpdateCoordinator, JobUpdateCoordinator, LegacyStatusCoordinator, + PrusaLinkConfigEntry, PrusaLinkUpdateCoordinator, StatusCoordinator, ) @@ -36,7 +37,7 @@ ] -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.") @@ -57,7 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 56be36c3e9d78a..fff24eef19502e 100644 --- a/homeassistant/components/prusalink/binary_sensor.py +++ b/homeassistant/components/prusalink/binary_sensor.py @@ -13,12 +13,10 @@ 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 +54,11 @@ class 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 59a63d874ee89d..a619204eb86e33 100644 --- a/homeassistant/components/prusalink/button.py +++ b/homeassistant/components/prusalink/button.py @@ -10,13 +10,11 @@ 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 +69,11 @@ class 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 +120,7 @@ async def async_press(self) -> None: "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 6aac03ca1798ed..0ab5d517d57443 100644 --- a/homeassistant/components/prusalink/camera.py +++ b/homeassistant/components/prusalink/camera.py @@ -5,22 +5,20 @@ 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 +29,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/coordinator.py b/homeassistant/components/prusalink/coordinator.py index 8d994fa728ae37..e50ef66815be1c 100644 --- a/homeassistant/components/prusalink/coordinator.py +++ b/homeassistant/components/prusalink/coordinator.py @@ -35,14 +35,17 @@ T = TypeVar("T", PrinterStatus, LegacyPrinterStatus, JobInfo) +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 diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py index cf4818e111eca9..dbfcb8886bc5b8 100644 --- a/homeassistant/components/prusalink/sensor.py +++ b/homeassistant/components/prusalink/sensor.py @@ -16,7 +16,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, REVOLUTIONS_PER_MINUTE, @@ -29,8 +28,7 @@ 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 +202,11 @@ class 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/pushbullet/__init__.py b/homeassistant/components/pushbullet/__init__.py index e5892afc9260fa..4adfbcad4f9d4e 100644 --- a/homeassistant/components/pushbullet/__init__.py +++ b/homeassistant/components/pushbullet/__init__.py @@ -21,6 +21,8 @@ from .api import PushBulletNotificationProvider from .const import DATA_HASS_CONFIG, DOMAIN +type PushbulletConfigEntry = ConfigEntry[PushBulletNotificationProvider] + PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -35,7 +37,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 +51,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 +74,8 @@ def start_listener(event: Event) -> None: 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/notify.py b/homeassistant/components/pushbullet/notify.py index f2e70695b27e45..26ecc859ad269d 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -22,8 +22,7 @@ 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 +35,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 3ab55ecf072eaf..ade6f9362ed938 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -3,13 +3,13 @@ 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 +69,12 @@ 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/notify.py b/homeassistant/components/pushover/notify.py index 62c14b4dae8bab..6721a4a3c4a5bd 100644 --- a/homeassistant/components/pushover/notify.py +++ b/homeassistant/components/pushover/notify.py @@ -45,6 +45,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 81dfc7e15fd11b..abb0c1bad61307 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/pvoutput/__init__.py b/homeassistant/components/pvoutput/__init__.py index 7dc02a07d1cf3e..9932ff24d148f5 100644 --- a/homeassistant/components/pvoutput/__init__.py +++ b/homeassistant/components/pvoutput/__init__.py @@ -2,26 +2,23 @@ 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 ad2d759056f496..2d9860c63e2a13 100644 --- a/homeassistant/components/pvoutput/config_flow.py +++ b/homeassistant/components/pvoutput/config_flow.py @@ -32,8 +32,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 +54,7 @@ async def async_step_user( 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 +81,45 @@ async def async_step_user( 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/coordinator.py b/homeassistant/components/pvoutput/coordinator.py index ce3642421bf080..2583410c37cbab 100644 --- a/homeassistant/components/pvoutput/coordinator.py +++ b/homeassistant/components/pvoutput/coordinator.py @@ -2,7 +2,14 @@ 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 +20,15 @@ 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 +44,20 @@ async def _async_update_data(self) -> 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 3b9007b77b4406..e75a0b59f20153 100644 --- a/homeassistant/components/pvoutput/diagnostics.py +++ b/homeassistant/components/pvoutput/diagnostics.py @@ -4,16 +4,13 @@ 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 dee5f9cda6e7e6..58792a0dbc569d 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 b4ed3f93945501..1a4a84bcbf0d28 100644 --- a/homeassistant/components/pvoutput/sensor.py +++ b/homeassistant/components/pvoutput/sensor.py @@ -13,7 +13,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfElectricPotential, UnitOfEnergy, @@ -26,7 +25,9 @@ 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 +98,11 @@ class PVOutputSensorEntityDescription(SensorEntityDescription): 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 f8fbf4581aec50..342ed952eb963e 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/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py index a69ba0c67dd88d..a9d1ed2a3281c0 100644 --- a/homeassistant/components/pyload/coordinator.py +++ b/homeassistant/components/pyload/coordinator.py @@ -68,7 +68,9 @@ async def _async_update_data(self) -> 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/manifest.json b/homeassistant/components/pyload/manifest.json index fe36327cc75487..2a008128f86e02 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/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 0729d73a034323..afed00363c9e19 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -35,7 +35,6 @@ 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 513b49d35618ec..62f671fc5c4df3 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -5,7 +5,7 @@ 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 @@ 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 handle_get_torrents(service_call: ServiceCall) -> dict[str, Any] | Non 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 handle_get_all_torrents( ) -> 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 handle_get_all_torrents( 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/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py index 8fd23fb3b5b9b5..007945a18e731d 100644 --- a/homeassistant/components/qbittorrent/coordinator.py +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -24,14 +24,16 @@ _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 afad29a5b731b0..c942dec6e6cc08 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -13,7 +13,6 @@ 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 +21,7 @@ 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 +235,12 @@ class QBittorrentSensorEntityDescription(SensorEntityDescription): 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 +257,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 dd61f130ca1582..176e0942b25956 100644 --- a/homeassistant/components/qbittorrent/switch.py +++ b/homeassistant/components/qbittorrent/switch.py @@ -7,14 +7,13 @@ 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 +41,12 @@ class QBittorrentSwitchEntityDescription(SwitchEntityDescription): 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 +63,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/qnap/__init__.py b/homeassistant/components/qnap/__init__.py index 82e912a60cd1d9..3315eadac76623 100644 --- a/homeassistant/components/qnap/__init__.py +++ b/homeassistant/components/qnap/__init__.py @@ -2,33 +2,27 @@ 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/coordinator.py b/homeassistant/components/qnap/coordinator.py index 8b6cb930b4ffb0..8351727183cb22 100644 --- a/homeassistant/components/qnap/coordinator.py +++ b/homeassistant/components/qnap/coordinator.py @@ -26,6 +26,8 @@ from .const import DOMAIN +type QnapConfigEntry = ConfigEntry[QnapCoordinator] + UPDATE_INTERVAL = timedelta(minutes=1) _LOGGER = logging.getLogger(__name__) @@ -46,7 +48,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 381455cb7e17b6..8f47ebf1428fec 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -5,7 +5,6 @@ from datetime import timedelta from typing import Any -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -20,14 +19,13 @@ 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 +245,11 @@ 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 f9faca025a52a2..8e90e06bc10234 100644 --- a/homeassistant/components/qnap_qsw/__init__.py +++ b/homeassistant/components/qnap_qsw/__init__.py @@ -6,14 +6,17 @@ 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 +28,7 @@ ] -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 +47,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 c1f77d068dfa35..bae91da4b488d1 100644 --- a/homeassistant/components/qnap_qsw/binary_sensor.py +++ b/homeassistant/components/qnap_qsw/binary_sensor.py @@ -20,14 +20,13 @@ 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 +78,11 @@ class 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 +137,7 @@ def __init__( 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 02cf96766f2679..8ca05db84cd8e4 100644 --- a/homeassistant/components/qnap_qsw/button.py +++ b/homeassistant/components/qnap_qsw/button.py @@ -13,13 +13,12 @@ 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 +41,11 @@ class QswButtonDescription(ButtonEntityDescription): 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 +62,7 @@ def __init__( self, coordinator: QswDataCoordinator, description: QswButtonDescription, - entry: ConfigEntry, + entry: QnapQswConfigEntry, ) -> None: """Initialize.""" super().__init__(coordinator, entry) diff --git a/homeassistant/components/qnap_qsw/const.py b/homeassistant/components/qnap_qsw/const.py index 4b5fa9a4a2c843..05eeea031b51a0 100644 --- a/homeassistant/components/qnap_qsw/const.py +++ b/homeassistant/components/qnap_qsw/const.py @@ -10,8 +10,6 @@ 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 b72bed7105ccab..6f369915a6c13d 100644 --- a/homeassistant/components/qnap_qsw/coordinator.py +++ b/homeassistant/components/qnap_qsw/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any @@ -22,13 +23,24 @@ _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 +66,10 @@ async def _async_update_data(self) -> 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 6f42fb82cb7d78..d6a8b95882903d 100644 --- a/homeassistant/components/qnap_qsw/diagnostics.py +++ b/homeassistant/components/qnap_qsw/diagnostics.py @@ -7,12 +7,10 @@ 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 +25,15 @@ 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 a3038b1fc7b5db..40670c9f28815b 100644 --- a/homeassistant/components/qnap_qsw/entity.py +++ b/homeassistant/components/qnap_qsw/entity.py @@ -16,7 +16,6 @@ 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 +23,7 @@ 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 +39,7 @@ class QswDataEntity(CoordinatorEntity[QswDataCoordinator]): def __init__( self, coordinator: QswDataCoordinator, - entry: ConfigEntry, + entry: QnapQswConfigEntry, type_id: int | None = None, ) -> None: """Initialize.""" @@ -127,7 +126,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 af02c121656af5..bed69472c85398 100644 --- a/homeassistant/components/qnap_qsw/sensor.py +++ b/homeassistant/components/qnap_qsw/sensor.py @@ -36,7 +36,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfDataRate, @@ -48,8 +47,8 @@ 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 +286,11 @@ class QswSensorEntityDescription(SensorEntityDescription, QswEntityDescription): 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 +353,7 @@ def __init__( 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 c5cef7298493fe..f9652d4e4f46db 100644 --- a/homeassistant/components/qnap_qsw/update.py +++ b/homeassistant/components/qnap_qsw/update.py @@ -17,13 +17,12 @@ 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 +36,11 @@ 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 +56,7 @@ def __init__( self, coordinator: QswFirmwareCoordinator, description: UpdateEntityDescription, - entry: ConfigEntry, + entry: QnapQswConfigEntry, ) -> None: """Initialize.""" super().__init__(coordinator, entry) diff --git a/homeassistant/components/rabbitair/__init__.py b/homeassistant/components/rabbitair/__init__.py index d6530b322b0dd8..fbae3b5c3cd638 100644 --- a/homeassistant/components/rabbitair/__init__.py +++ b/homeassistant/components/rabbitair/__init__.py @@ -5,21 +5,17 @@ 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 +26,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 +35,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/coordinator.py b/homeassistant/components/rabbitair/coordinator.py index 75453fe4d2445e..ccc9566a6229dc 100644 --- a/homeassistant/components/rabbitair/coordinator.py +++ b/homeassistant/components/rabbitair/coordinator.py @@ -12,6 +12,8 @@ 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 @@ def has_pending_call(self) -> bool: 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 47a1b7db3eb41c..dc5e69ed7a5268 100644 --- a/homeassistant/components/rabbitair/entity.py +++ b/homeassistant/components/rabbitair/entity.py @@ -7,13 +7,12 @@ 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 +30,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 4c13f3a8b02f2b..8494253605439b 100644 --- a/homeassistant/components/rabbitair/fan.py +++ b/homeassistant/components/rabbitair/fan.py @@ -7,7 +7,6 @@ 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 +14,7 @@ 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 +38,11 @@ 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 +58,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/radarr/calendar.py b/homeassistant/components/radarr/calendar.py index d3a30bf6ce9cf1..4bca75123e0416 100644 --- a/homeassistant/components/radarr/calendar.py +++ b/homeassistant/components/radarr/calendar.py @@ -56,7 +56,7 @@ async def async_get_events( # type: ignore[override] return await self.coordinator.async_get_events(start_date, end_date) @callback - def async_write_ha_state(self) -> None: + def _async_write_ha_state(self) -> None: """Write the state to the state machine.""" if self.coordinator.event: self._attr_extra_state_attributes = { @@ -64,4 +64,4 @@ def async_write_ha_state(self) -> None: } else: self._attr_extra_state_attributes = {} - super().async_write_ha_state() + super()._async_write_ha_state() diff --git a/homeassistant/components/radio_frequency/__init__.py b/homeassistant/components/radio_frequency/__init__.py new file mode 100644 index 00000000000000..c7c58f64df23cf --- /dev/null +++ b/homeassistant/components/radio_frequency/__init__.py @@ -0,0 +1,228 @@ +"""Provides functionality to interact with radio frequency devices.""" + +from __future__ import annotations + +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 00000000000000..04d50de7d8ed16 --- /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 00000000000000..c7587d1f77070a --- /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 00000000000000..70797a9cb87641 --- /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 00000000000000..9674cd260236ac --- /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 80dbcf44bc9252..54ddda99bc6f14 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -1,4 +1,4 @@ -"""The radiotherm component.""" +"""The Radio Thermostat integration.""" from __future__ import annotations @@ -8,13 +8,11 @@ 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 +36,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 +52,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 8ede90f2718685..920523c2f43753 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -17,13 +17,11 @@ 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 +99,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/coordinator.py b/homeassistant/components/radiotherm/coordinator.py index 7d483426c83e2d..2ed913bfb035af 100644 --- a/homeassistant/components/radiotherm/coordinator.py +++ b/homeassistant/components/radiotherm/coordinator.py @@ -14,6 +14,8 @@ from .data import RadioThermInitData, RadioThermUpdate, async_get_data +type RadioThermConfigEntry = ConfigEntry[RadioThermUpdateCoordinator] + _LOGGER = logging.getLogger(__name__) UPDATE_INTERVAL = timedelta(seconds=15) @@ -22,12 +24,12 @@ 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 4803cacd84b93a..92e2ee42273d39 100644 --- a/homeassistant/components/radiotherm/data.py +++ b/homeassistant/components/radiotherm/data.py @@ -1,4 +1,4 @@ -"""The radiotherm component data.""" +"""The Radio Thermostat integration data.""" from __future__ import annotations @@ -16,7 +16,7 @@ @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 384c97cac2ce8e..7735336624852e 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 @@ 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 2952e1e58176f7..eaced4b4386136 100644 --- a/homeassistant/components/radiotherm/switch.py +++ b/homeassistant/components/radiotherm/switch.py @@ -5,12 +5,10 @@ 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 +16,11 @@ 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/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 9563d9b7268926..b8a77b87fafdb9 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/rainforest_eagle/__init__.py b/homeassistant/components/rainforest_eagle/__init__.py index 5be2e778c5d241..9bb1cc8ad43a66 100644 --- a/homeassistant/components/rainforest_eagle/__init__.py +++ b/homeassistant/components/rainforest_eagle/__init__.py @@ -2,29 +2,27 @@ 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/coordinator.py b/homeassistant/components/rainforest_eagle/coordinator.py index 11956681638d51..9b09319804e720 100644 --- a/homeassistant/components/rainforest_eagle/coordinator.py +++ b/homeassistant/components/rainforest_eagle/coordinator.py @@ -23,17 +23,21 @@ ) 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/diagnostics.py b/homeassistant/components/rainforest_eagle/diagnostics.py index ec40f2515b1281..c37a45b4e751e6 100644 --- a/homeassistant/components/rainforest_eagle/diagnostics.py +++ b/homeassistant/components/rainforest_eagle/diagnostics.py @@ -5,22 +5,19 @@ 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 6f4cbf4f02c652..297cfd7fa354bb 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -8,7 +8,6 @@ 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 +16,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import EagleDataCoordinator +from .coordinator import EagleDataCoordinator, RainforestEagleConfigEntry SENSORS = ( SensorEntityDescription( @@ -46,11 +45,11 @@ 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_raven/config_flow.py b/homeassistant/components/rainforest_raven/config_flow.py index f8e3dde446ae6f..ac6584f6830fa7 100644 --- a/homeassistant/components/rainforest_raven/config_flow.py +++ b/homeassistant/components/rainforest_raven/config_flow.py @@ -8,8 +8,6 @@ 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 +23,19 @@ 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 +102,7 @@ async def async_step_meters( 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 +119,29 @@ async def async_step_user( """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 +153,5 @@ async def async_step_user( 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/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 6ce95d7e547006..0369590b7dbb2d 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -102,7 +102,10 @@ async def async_step_homekit_zeroconf( # 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/rapt_ble/sensor.py b/homeassistant/components/rapt_ble/sensor.py index 01aeedbd344605..c5cdc3869019e3 100644 --- a/homeassistant/components/rapt_ble/sensor.py +++ b/homeassistant/components/rapt_ble/sensor.py @@ -102,6 +102,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the RAPT Pill 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/rdw/__init__.py b/homeassistant/components/rdw/__init__.py index 7a2cfbf6df3962..f668f1abceefb3 100644 --- a/homeassistant/components/rdw/__init__.py +++ b/homeassistant/components/rdw/__init__.py @@ -2,30 +2,25 @@ 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 d407cfc1b87ee8..5db3c446d63da2 100644 --- a/homeassistant/components/rdw/binary_sensor.py +++ b/homeassistant/components/rdw/binary_sensor.py @@ -12,14 +12,13 @@ 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 +45,32 @@ class RDWBinarySensorEntityDescription(BinarySensorEntityDescription): 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/coordinator.py b/homeassistant/components/rdw/coordinator.py index 2b9bb866790c71..6a2b7893ebc216 100644 --- a/homeassistant/components/rdw/coordinator.py +++ b/homeassistant/components/rdw/coordinator.py @@ -2,22 +2,24 @@ 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 +35,15 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: 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 bf5f8fbd904467..0f79a5f19649d4 100644 --- a/homeassistant/components/rdw/diagnostics.py +++ b/homeassistant/components/rdw/diagnostics.py @@ -4,17 +4,14 @@ 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 00000000000000..df94f77b738550 --- /dev/null +++ b/homeassistant/components/rdw/entity.py @@ -0,0 +1,27 @@ +"""Base entity for the RDW integration.""" + +from __future__ import annotations + +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 2ab90e55ef0828..647b25ada6a5ff 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 08e7d772d15f39..ad88d2eaabdf9b 100644 --- a/homeassistant/components/rdw/sensor.py +++ b/homeassistant/components/rdw/sensor.py @@ -13,14 +13,13 @@ 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 +47,29 @@ class RDWSensorEntityDescription(SensorEntityDescription): 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 5a2683588a42f9..16480fe4e0d8c6 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 c805b49144090a..c714383e4f3fcc 100644 --- a/homeassistant/components/recollect_waste/__init__.py +++ b/homeassistant/components/recollect_waste/__init__.py @@ -9,19 +9,20 @@ 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 +31,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 f057d1c3368543..273712731fdac0 100644 --- a/homeassistant/components/recollect_waste/calendar.py +++ b/homeassistant/components/recollect_waste/calendar.py @@ -7,19 +7,17 @@ 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 +34,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 +50,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 299af2609e34e6..a0bec85e01015a 100644 --- a/homeassistant/components/recollect_waste/config_flow.py +++ b/homeassistant/components/recollect_waste/config_flow.py @@ -8,17 +8,13 @@ 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 +29,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 4a7e9d58b125e2..c2a38258c2361b 100644 --- a/homeassistant/components/recollect_waste/coordinator.py +++ b/homeassistant/components/recollect_waste/coordinator.py @@ -14,15 +14,19 @@ 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 a9007eb5d2c3c0..21c2cb3f61d6c1 100644 --- a/homeassistant/components/recollect_waste/diagnostics.py +++ b/homeassistant/components/recollect_waste/diagnostics.py @@ -6,12 +6,11 @@ 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 +25,13 @@ 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 891f1706f77b15..6d051b548a5aab 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 97d6c1413e13f5..8ab5efca00c7de 100644 --- a/homeassistant/components/recollect_waste/sensor.py +++ b/homeassistant/components/recollect_waste/sensor.py @@ -9,12 +9,11 @@ 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 +37,12 @@ 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 +60,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 a350feac5190d7..b7e2d3e3604a49 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -25,7 +25,6 @@ ) 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 +127,6 @@ def validate_db_url(db_url: str) -> Any: ) -@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/db_schema.py b/homeassistant/components/recorder/db_schema.py index 65de7e853a3709..896fd53b8886d1 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -192,7 +192,7 @@ def result_processor(self, dialect: Dialect, coltype: Any) -> Callable | None: # 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 +206,12 @@ def result_processor(self, dialect: Dialect, coltype: Any) -> Callable | None: ) 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/manifest.json b/homeassistant/components/recorder/manifest.json index f4b37d36742fed..b71725e078672a 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/statistics.py b/homeassistant/components/recorder/statistics.py index 517bf77b282e84..b4e451a082cce4 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -57,6 +57,7 @@ ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -214,6 +215,7 @@ def query_circular_mean(table: type[StatisticsBase]) -> tuple[Label, Label]: ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 53beb6b43c2121..f0ee3a02c7108e 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -447,10 +447,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 58dfd2271d2b47..42cca8cf2dfd95 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -30,6 +30,7 @@ ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -90,6 +91,7 @@ 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 5837a648ecbf24..4323b54ac55cd9 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/refoss/__init__.py b/homeassistant/components/refoss/__init__.py index eb2085efda4a18..310a8afd284f01 100644 --- a/homeassistant/components/refoss/__init__.py +++ b/homeassistant/components/refoss/__init__.py @@ -5,13 +5,12 @@ 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 +19,11 @@ ] -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 +41,7 @@ async def _async_scan_update(_=None): 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 a3ba9ea663d526..ec5ae20deb9c8f 100644 --- a/homeassistant/components/refoss/bridge.py +++ b/homeassistant/components/refoss/bridge.py @@ -10,15 +10,17 @@ 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 +29,7 @@ def __init__( 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 +39,7 @@ async def device_found(self, device_info: DeviceInfo) -> None: 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 +51,7 @@ async def device_found(self, device_info: DeviceInfo) -> None: 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/sensor.py b/homeassistant/components/refoss/sensor.py index 92090a192e8e25..b7be46a649c683 100644 --- a/homeassistant/components/refoss/sensor.py +++ b/homeassistant/components/refoss/sensor.py @@ -13,7 +13,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfElectricCurrent, UnitOfElectricPotential, @@ -25,15 +24,8 @@ 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 +108,7 @@ class RefossSensorEntityDescription(SensorEntityDescription): 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 +138,7 @@ def init_device(coordinator: RefossDataUpdateCoordinator) -> None: ) _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 1d465f7f3197c5..348851b9cc0fd0 100644 --- a/homeassistant/components/refoss/switch.py +++ b/homeassistant/components/refoss/switch.py @@ -7,25 +7,24 @@ 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 +38,7 @@ def init_device(coordinator): 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/rehlko/strings.json b/homeassistant/components/rehlko/strings.json index e802d234c93ae9..3950a2eb7d9618 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/remote/__init__.py b/homeassistant/components/remote/__init__.py index f7d87fbf02165a..8fe98868b569e0 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -25,7 +25,6 @@ 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 +72,6 @@ class RemoteEntityFeature(IntFlag): ) -@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 00000000000000..51788c95fa8be1 --- /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 00000000000000..8556406d47635e --- /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/icons.json b/homeassistant/components/remote/icons.json index 1560336d7c1a92..1436e21e2b654e 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/strings.json b/homeassistant/components/remote/strings.json index 8cad5e289acfda..3603c54df1bba9 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -1,6 +1,35 @@ { "common": { - "trigger_behavior_name": "Trigger when" + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" + }, + "conditions": { + "is_off": { + "description": "Tests if one or more remotes are off.", + "fields": { + "behavior": { + "name": "[%key:component::remote::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::remote::common::condition_for_name%]" + } + }, + "name": "Remote is off" + }, + "is_on": { + "description": "Tests if one or more remotes are on.", + "fields": { + "behavior": { + "name": "[%key:component::remote::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::remote::common::condition_for_name%]" + } + }, + "name": "Remote is on" + } }, "device_automation": { "action_type": { @@ -30,18 +59,9 @@ } } }, - "selector": { - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "delete_command": { - "description": "Deletes a command or a list of commands from the database.", + "description": "Deletes a command or a list of commands from a remote's database.", "fields": { "command": { "description": "The single command or the list of commands to be deleted.", @@ -52,10 +72,10 @@ "name": "Device" } }, - "name": "Delete command" + "name": "Delete remote command" }, "learn_command": { - "description": "Learns a command or a list of commands from a device.", + "description": "Teaches a remote a command or list of commands from a device.", "fields": { "alternative": { "description": "If code must be stored as an alternative. This is useful for discrete codes. Discrete codes are used for toggles that only perform one function. For example, a code to only turn a device on. If it is on already, sending the code won't change the state.", @@ -78,7 +98,7 @@ "name": "Timeout" } }, - "name": "Learn command" + "name": "Learn remote command" }, "send_command": { "description": "Sends a command or a list of commands to a device.", @@ -104,15 +124,15 @@ "name": "Repeats" } }, - "name": "Send command" + "name": "Send remote command" }, "toggle": { "description": "Sends the toggle command.", - "name": "[%key:common::action::toggle%]" + "name": "Toggle via remote" }, "turn_off": { "description": "Sends the turn off command.", - "name": "[%key:common::action::turn_off%]" + "name": "Turn off via remote" }, "turn_on": { "description": "Sends the turn on command.", @@ -122,7 +142,7 @@ "name": "Activity" } }, - "name": "[%key:common::action::turn_on%]" + "name": "Turn on via remote" } }, "title": "Remote", @@ -132,6 +152,9 @@ "fields": { "behavior": { "name": "[%key:component::remote::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::remote::common::trigger_for_name%]" } }, "name": "Remote turned off" @@ -141,6 +164,9 @@ "fields": { "behavior": { "name": "[%key:component::remote::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::remote::common::trigger_for_name%]" } }, "name": "Remote turned on" diff --git a/homeassistant/components/remote/triggers.yaml b/homeassistant/components/remote/triggers.yaml index 6dadeba1fd2a9a..559577735cf894 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/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 5e4f08e9d5c7b2..4c09ca44601de4 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -6,7 +6,12 @@ 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 +20,6 @@ ) 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 +39,13 @@ @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 +63,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/coordinator.py b/homeassistant/components/renault/coordinator.py index c768c436133707..481c27c42db765 100644 --- a/homeassistant/components/renault/coordinator.py +++ b/homeassistant/components/renault/coordinator.py @@ -6,7 +6,7 @@ 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 +23,13 @@ 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 c55ddeb2190a99..795e0ce80b2035 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -52,12 +52,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 5d1849f4b207a5..5a8cb41beca337 100644 --- a/homeassistant/components/renault/diagnostics.py +++ b/homeassistant/components/renault/diagnostics.py @@ -56,8 +56,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 81d81a18b7f59a..23dfe67f501b93 100644 --- a/homeassistant/components/renault/entity.py +++ b/homeassistant/components/renault/entity.py @@ -3,28 +3,23 @@ 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 +38,7 @@ def __init__( 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 +52,6 @@ def __init__( 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/icons.json b/homeassistant/components/renault/icons.json index f1767dcfbf619a..00b607bea7a100 100644 --- a/homeassistant/components/renault/icons.json +++ b/homeassistant/components/renault/icons.json @@ -88,6 +88,9 @@ }, "charge_set_schedules": { "service": "mdi:calendar-clock" + }, + "charge_start": { + "service": "mdi:ev-station" } } } diff --git a/homeassistant/components/renault/number.py b/homeassistant/components/renault/number.py index 555bb9b9e72b9e..6891b8313e063e 100644 --- a/homeassistant/components/renault/number.py +++ b/homeassistant/components/renault/number.py @@ -4,9 +4,12 @@ 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 +32,23 @@ @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 +56,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 +72,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 +86,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 +105,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 +123,9 @@ async def async_set_native_value(self, value: float) -> None: 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 +134,11 @@ async def async_set_native_value(self, value: float) -> None: 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 +147,6 @@ async def async_set_native_value(self, value: float) -> None: 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_vehicle.py b/homeassistant/components/renault/renault_vehicle.py index e2acb1bc07d397..49b91c5cd38d8d 100644 --- a/homeassistant/components/renault/renault_vehicle.py +++ b/homeassistant/components/renault/renault_vehicle.py @@ -165,9 +165,11 @@ async def set_charge_mode( return await self._vehicle.set_charge_mode(charge_mode) @with_error_wrapping - async def set_charge_start(self) -> models.KamereonVehicleChargingStartActionData: + async def set_charge_start( + self, when: datetime | None = None + ) -> models.KamereonVehicleChargingStartActionData: """Start vehicle charge.""" - return await self._vehicle.set_charge_start() + return await self._vehicle.set_charge_start(when) @with_error_wrapping async def set_charge_stop(self) -> models.KamereonVehicleChargingStartActionData: diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index cddf83bb8603c2..514378411b5319 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -2,15 +2,18 @@ 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 +24,13 @@ @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 +48,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 66e1a4be93b81f..641b5b1847a03f 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -5,12 +5,13 @@ 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 +40,6 @@ 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 +48,14 @@ @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 +65,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 +75,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 +152,46 @@ def _get_charging_settings_mode_formatted(entity: RenaultSensor[T]) -> str | Non ], 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 +201,119 @@ def _get_charging_settings_mode_formatted(entity: RenaultSensor[T]) -> str | Non ], 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 +322,40 @@ def _get_charging_settings_mode_formatted(entity: RenaultSensor[T]) -> str | Non ], 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 03531924533c6a..a8811ff231bdd4 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -36,6 +36,11 @@ vol.Optional(ATTR_WHEN): cv.datetime, } ) +SERVICE_CHARGE_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend( + { + vol.Optional(ATTR_WHEN): cv.datetime, + } +) SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA = vol.Schema( { vol.Required("startTime"): cv.string, @@ -113,6 +118,16 @@ async def ac_start(service_call: ServiceCall) -> None: LOGGER.debug("A/C start result: %s", result.raw_data) +async def charge_start(service_call: ServiceCall) -> None: + """Start Charging with optional delay.""" + when: datetime | None = service_call.data.get(ATTR_WHEN) + proxy = get_vehicle_proxy(service_call) + + LOGGER.debug("Charge start attempt, when: %s", when) + result = await proxy.set_charge_start(when) + LOGGER.debug("Charge start result: %s", result.raw_data) + + async def charge_set_schedules(service_call: ServiceCall) -> None: """Set charge schedules.""" schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES] @@ -196,6 +211,12 @@ def async_setup_services(hass: HomeAssistant) -> None: ac_start, schema=SERVICE_AC_START_SCHEMA, ) + hass.services.async_register( + DOMAIN, + "charge_start", + charge_start, + schema=SERVICE_CHARGE_START_SCHEMA, + ) hass.services.async_register( DOMAIN, "charge_set_schedules", diff --git a/homeassistant/components/renault/services.yaml b/homeassistant/components/renault/services.yaml index 835a57bd9c1bc4..98411f1733e696 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: @@ -54,6 +54,18 @@ ac_set_schedules: selector: object: +charge_start: + fields: + vehicle: + required: true + selector: + device: + integration: renault + when: + example: "2026-03-01T17:45:00" + selector: + datetime: + charge_set_schedules: fields: vehicle: diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json index a58575f68a3259..21e8dfff06e4d1 100644 --- a/homeassistant/components/renault/strings.json +++ b/homeassistant/components/renault/strings.json @@ -276,6 +276,20 @@ } }, "name": "Update charge schedule" + }, + "charge_start": { + "description": "Starts charging on vehicle.", + "fields": { + "vehicle": { + "description": "[%key:component::renault::services::ac_start::fields::vehicle::description%]", + "name": "Vehicle" + }, + "when": { + "description": "Timestamp for charging to start (optional - defaults to now).", + "name": "When" + } + }, + "name": "Start charging" } } } diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index b88f9bb036a30e..95686526e74b19 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -2,17 +2,13 @@ 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 +21,7 @@ ] -@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 +32,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 +42,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 60b4f54b85ce91..aba3a889d77108 100644 --- a/homeassistant/components/renson/binary_sensor.py +++ b/homeassistant/components/renson/binary_sensor.py @@ -21,13 +21,11 @@ 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 +83,13 @@ class RensonBinarySensorEntityDescription(BinarySensorEntityDescription): 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 830e5a03a4ab85..5cdda11c6da4cc 100644 --- a/homeassistant/components/renson/button.py +++ b/homeassistant/components/renson/button.py @@ -12,13 +12,11 @@ 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 +51,12 @@ class RensonButtonEntityDescription(ButtonEntityDescription): 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/coordinator.py b/homeassistant/components/renson/coordinator.py index 5d0a20e1c29313..1f31ad99c6c0df 100644 --- a/homeassistant/components/renson/coordinator.py +++ b/homeassistant/components/renson/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from datetime import timedelta import logging from typing import Any @@ -15,18 +16,29 @@ 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/fan.py b/homeassistant/components/renson/fan.py index c82cad012c31b2..0f2822821cddab 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -16,7 +16,6 @@ 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 +26,7 @@ ) 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 +82,13 @@ 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 67fde1c56dc12f..36d99b71897696 100644 --- a/homeassistant/components/renson/number.py +++ b/homeassistant/components/renson/number.py @@ -12,13 +12,11 @@ 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 +37,13 @@ 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 ce7e71b1c0b910..2a38c58890188c 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -34,7 +34,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -45,9 +44,7 @@ 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 +268,12 @@ def _handle_coordinator_update(self) -> None: 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 3b73bb3dffef7e..4f331c1e49d100 100644 --- a/homeassistant/components/renson/switch.py +++ b/homeassistant/components/renson/switch.py @@ -9,12 +9,10 @@ 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 +65,12 @@ def _handle_coordinator_update(self) -> None: 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 0a07fd2ec4f42f..636790a98423f6 100644 --- a/homeassistant/components/renson/time.py +++ b/homeassistant/components/renson/time.py @@ -10,14 +10,11 @@ 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 +46,14 @@ class RensonTimeEntityDescription(TimeEntityDescription): 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/strings.json b/homeassistant/components/reolink/strings.json index 6a707d6ff72e51..979154776a7ca7 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/rest/manifest.json b/homeassistant/components/rest/manifest.json index bd94e07636be23..a9a2b5ac9dfff9 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/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index 8692ff40366961..f3fa584fad044c 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -270,6 +270,8 @@ def _updated_device(event: Event[EventDeviceRegistryUpdatedData]) -> 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/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 53e14fdddf7413..cdc4d3cc55a5bc 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -6,14 +6,12 @@ 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 +554,7 @@ async def async_step_setup_serial( 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 +564,12 @@ async def async_step_setup_serial( 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 +650,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/device_action.py b/homeassistant/components/rfxtrx/device_action.py index c3f61dee0265f1..d4160497672313 100644 --- a/homeassistant/components/rfxtrx/device_action.py +++ b/homeassistant/components/rfxtrx/device_action.py @@ -96,6 +96,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/entity.py b/homeassistant/components/rfxtrx/entity.py index f0cc193023c471..3d5525dee6ee6d 100644 --- a/homeassistant/components/rfxtrx/entity.py +++ b/homeassistant/components/rfxtrx/entity.py @@ -119,5 +119,7 @@ def __init__( 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/manifest.json b/homeassistant/components/rfxtrx/manifest.json index 34df4c26c18611..a6958ae49d72ee 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/ridwell/__init__.py b/homeassistant/components/ridwell/__init__.py index 84c389e05d61fd..2778cdcfda1877 100644 --- a/homeassistant/components/ridwell/__init__.py +++ b/homeassistant/components/ridwell/__init__.py @@ -9,17 +9,17 @@ 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 +27,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 f1c5e6bc427e3b..d882e5a1e2ee13 100644 --- a/homeassistant/components/ridwell/calendar.py +++ b/homeassistant/components/ridwell/calendar.py @@ -7,7 +7,6 @@ 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 +15,14 @@ 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 +64,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 de7201c5f9a3fc..22f61a68cc40bc 100644 --- a/homeassistant/components/ridwell/config_flow.py +++ b/homeassistant/components/ridwell/config_flow.py @@ -9,7 +9,7 @@ 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 +19,7 @@ ) from .const import CALENDAR_TITLE_OPTIONS, CONF_CALENDAR_TITLE, DOMAIN, LOGGER +from .coordinator import RidwellConfigEntry STEP_REAUTH_CONFIRM_DATA_SCHEMA = vol.Schema( { @@ -107,7 +108,7 @@ async def _async_validate( @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 336a71bc67f1e8..6472f631966668 100644 --- a/homeassistant/components/ridwell/coordinator.py +++ b/homeassistant/components/ridwell/coordinator.py @@ -19,6 +19,8 @@ from .const import LOGGER +type RidwellConfigEntry = ConfigEntry[RidwellDataUpdateCoordinator] + UPDATE_INTERVAL = timedelta(hours=1) @@ -27,9 +29,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 0eff7583311a27..785be65ce18af9 100644 --- a/homeassistant/components/ridwell/diagnostics.py +++ b/homeassistant/components/ridwell/diagnostics.py @@ -6,12 +6,10 @@ 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 +23,15 @@ 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/sensor.py b/homeassistant/components/ridwell/sensor.py index 30f97ecaea8deb..e9cea7b7676e17 100644 --- a/homeassistant/components/ridwell/sensor.py +++ b/homeassistant/components/ridwell/sensor.py @@ -13,12 +13,11 @@ 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 +34,11 @@ 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 e3be9ea5368ea2..fdf1bf0b1f262c 100644 --- a/homeassistant/components/ridwell/switch.py +++ b/homeassistant/components/ridwell/switch.py @@ -8,13 +8,11 @@ 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 +23,11 @@ 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/event.py b/homeassistant/components/ring/event.py index db99a10de74fda..4734bf68f81dee 100644 --- a/homeassistant/components/ring/event.py +++ b/homeassistant/components/ring/event.py @@ -7,6 +7,7 @@ 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 @@ class RingEventEntityDescription(EventEntityDescription, Generic[RingDeviceT]): 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 @@ def _get_coordinator_alert(self) -> RingAlert | None: @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/strings.json b/homeassistant/components/ring/strings.json index 1159a8b906e690..e7321b207fbe88 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/risco/__init__.py b/homeassistant/components/risco/__init__.py index d65bd5d5abf982..bdae79a0852860 100644 --- a/homeassistant/components/risco/__init__.py +++ b/homeassistant/components/risco/__init__.py @@ -26,15 +26,13 @@ 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 +56,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 +64,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 +120,15 @@ async def _system(system: System) -> None: 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 +144,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 +157,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 f485c923776084..7ab65830f96f18 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -15,19 +15,16 @@ 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 +33,7 @@ ) from .coordinator import RiscoDataUpdateCoordinator from .entity import RiscoCloudEntity +from .models import RiscoConfigEntry _LOGGER = logging.getLogger(__name__) @@ -49,13 +47,13 @@ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RiscoConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Risco alarm control panel.""" options = {**DEFAULT_OPTIONS, **config_entry.options} - if is_local(config_entry): - local_data: LocalData = hass.data[DOMAIN][config_entry.entry_id] + risco_data = config_entry.runtime_data + if local_data := risco_data.local_data: async_add_entities( RiscoLocalAlarm( local_data.system.id, @@ -67,10 +65,8 @@ async def async_setup_entry( ) for partition_id, partition in local_data.system.partitions.items() ) - else: - coordinator: RiscoDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ][DATA_COORDINATOR] + elif cloud_data := risco_data.cloud_data: + coordinator = cloud_data.coordinator async_add_entities( RiscoCloudAlarm( coordinator, partition_id, config_entry.data[CONF_PIN], options diff --git a/homeassistant/components/risco/binary_sensor.py b/homeassistant/components/risco/binary_sensor.py index ff61985fef3846..06f91a091f2833 100644 --- a/homeassistant/components/risco/binary_sensor.py +++ b/homeassistant/components/risco/binary_sensor.py @@ -15,16 +15,15 @@ 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 +71,12 @@ 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 +95,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 f7365d354147b7..141cafa78ecfae 100644 --- a/homeassistant/components/risco/config_flow.py +++ b/homeassistant/components/risco/config_flow.py @@ -10,12 +10,7 @@ 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 +37,7 @@ RISCO_STATES, TYPE_LOCAL, ) +from .models import RiscoConfigEntry _LOGGER = logging.getLogger(__name__) @@ -121,12 +117,12 @@ class RiscoConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Init the config flow.""" - self._reauth_entry: ConfigEntry | None = None + self._reauth_entry: RiscoConfigEntry | None = None @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: RiscoConfigEntry, ) -> RiscoOptionsFlowHandler: """Define the config flow to handle options.""" return RiscoOptionsFlowHandler(config_entry) @@ -218,7 +214,7 @@ async def async_step_local( 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 +234,8 @@ def _options_schema(self) -> vol.Schema: 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/models.py b/homeassistant/components/risco/models.py index 07777839e884ba..6d10be5ef87bca 100644 --- a/homeassistant/components/risco/models.py +++ b/homeassistant/components/risco/models.py @@ -1,11 +1,39 @@ """Models for Risco integration.""" +from __future__ import annotations + 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 93683f1aa50631..943e3d7c477e20 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -10,17 +10,16 @@ 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 +44,15 @@ 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 4ea8f6edd4f0e6..d48621219c3fcc 100644 --- a/homeassistant/components/risco/services.py +++ b/homeassistant/components/risco/services.py @@ -4,26 +4,26 @@ 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 _set_time(service_call: ServiceCall) -> 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 547dedd393312a..f6e5a058224624 100644 --- a/homeassistant/components/risco/switch.py +++ b/homeassistant/components/risco/switch.py @@ -7,33 +7,29 @@ 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 f2f1fcccfdc45f..44bfe044a28529 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -13,8 +13,8 @@ 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 @@ ] -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 97e9c8418d1177..27c6e8b43067a5 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -12,15 +12,15 @@ 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 +43,11 @@ class RitualsBinarySensorEntityDescription(BinarySensorEntityDescription): 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/coordinator.py b/homeassistant/components/rituals_perfume_genie/coordinator.py index 8513c994320ca4..c65699b73fcefa 100644 --- a/homeassistant/components/rituals_perfume_genie/coordinator.py +++ b/homeassistant/components/rituals_perfume_genie/coordinator.py @@ -15,11 +15,13 @@ _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 bcc61a01ad62eb..625e4e5522f88e 100644 --- a/homeassistant/components/rituals_perfume_genie/diagnostics.py +++ b/homeassistant/components/rituals_perfume_genie/diagnostics.py @@ -5,11 +5,9 @@ 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 +16,12 @@ 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/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 98e833ff9bd4cd..47c55312486905 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -9,14 +9,14 @@ 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 +40,11 @@ class RitualsNumberEntityDescription(NumberEntityDescription): 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 0636888c3d2347..6ce947f9a2d2e5 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -8,15 +8,15 @@ 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 +43,11 @@ class RitualsSelectEntityDescription(SelectEntityDescription): 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 3921fd0b6c209e..45beb9e3cd6486 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -12,15 +12,15 @@ 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 +59,11 @@ class RitualsSensorEntityDescription(SensorEntityDescription): 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 c5331b490781c4..a0b68cf44f53f8 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -9,14 +9,14 @@ 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 +41,11 @@ class RitualsSwitchEntityDescription(SwitchEntityDescription): 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/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index aa468570b0481b..8cffd29357d165 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -30,6 +30,8 @@ from .const import ( CONF_BASE_URL, CONF_SHOW_BACKGROUND, + CONF_SHOW_ROOMS, + CONF_SHOW_WALLS, CONF_USER_DATA, DEFAULT_DRAWABLES, DOMAIN, @@ -87,6 +89,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/button.py b/homeassistant/components/roborock/button.py index dfeaba0026cc72..9e9c1c60669ea1 100644 --- a/homeassistant/components/roborock/button.py +++ b/homeassistant/components/roborock/button.py @@ -3,11 +3,13 @@ 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 +45,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 +83,24 @@ class RoborockButtonDescription(ButtonEntityDescription): 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 +155,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 +204,14 @@ def __init__( 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 3cf0848ca45734..4061564c3bda0e 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -42,6 +42,8 @@ CONF_ENTRY_CODE, CONF_REGION, CONF_SHOW_BACKGROUND, + CONF_SHOW_ROOMS, + CONF_SHOW_WALLS, CONF_USER_DATA, DEFAULT_DRAWABLES, DOMAIN, @@ -246,6 +248,8 @@ async def async_step_drawables( """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 +268,18 @@ async def async_step_drawables( 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 9393b58d6d935f..1ed0df695b8670 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -11,8 +11,11 @@ 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 146ba9653651ea..ac8e9a3fe4a870 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -550,6 +550,7 @@ def __init__( RoborockB01Props.WIND, RoborockB01Props.WATER, RoborockB01Props.MODE, + RoborockB01Props.CLEAN_PATH_PREFERENCE, RoborockB01Props.QUANTITY, ] @@ -608,8 +609,9 @@ def __init__( 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/icons.json b/homeassistant/components/roborock/icons.json index f6053090bb7a97..71018ee9e14e31 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 04f4fbfa29a120..49f08024092018 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 0ff27d8145f945..2ba3d611bb353b 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -8,6 +8,7 @@ from roborock import B01Props, CleanTypeMapping from roborock.data import ( + CleanPathPreferenceMapping, RoborockDockDustCollectionModeCode, RoborockEnum, WaterLevelMapping, @@ -20,6 +21,7 @@ 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 .const import DOMAIN, MAP_SLEEP from .coordinator import ( RoborockB01Q7UpdateCoordinator, + RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, @@ -44,6 +47,7 @@ from .entity import ( RoborockCoordinatedEntityA01, RoborockCoordinatedEntityB01Q7, + RoborockCoordinatedEntityB01Q10, RoborockCoordinatedEntityV1, ) @@ -115,6 +119,16 @@ class RoborockSelectDescriptionA01(SelectEntityDescription): 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 @@ def current_option(self) -> str | None: 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 48f5407f86cee1..a52b096383cc36 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -19,6 +19,8 @@ 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 +36,7 @@ from .coordinator import ( RoborockB01Q7UpdateCoordinator, + RoborockB01Q10UpdateCoordinator, RoborockConfigEntry, RoborockDataUpdateCoordinator, RoborockDataUpdateCoordinatorA01, @@ -43,6 +46,7 @@ from .entity import ( RoborockCoordinatedEntityA01, RoborockCoordinatedEntityB01Q7, + RoborockCoordinatedEntityB01Q10, RoborockCoordinatedEntityV1, RoborockEntity, ) @@ -77,6 +81,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 +257,7 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: 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 +424,105 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: ] +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 +571,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 +684,30 @@ def __init__( 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 e3ba066f9ba9a2..dcd09fe973fa91 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" }, @@ -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/romy/__init__.py b/homeassistant/components/romy/__init__.py index be22764512274e..a067100bc184ed 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 599c0fe023e54f..f454efacdbc70a 100644 --- a/homeassistant/components/romy/binary_sensor.py +++ b/homeassistant/components/romy/binary_sensor.py @@ -5,12 +5,10 @@ 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 @@ 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/coordinator.py b/homeassistant/components/romy/coordinator.py index de5352191d7526..f72b388c3ca2a0 100644 --- a/homeassistant/components/romy/coordinator.py +++ b/homeassistant/components/romy/coordinator.py @@ -8,14 +8,16 @@ 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 85bf0df8f64721..8318924c28aa59 100644 --- a/homeassistant/components/romy/sensor.py +++ b/homeassistant/components/romy/sensor.py @@ -6,7 +6,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -18,8 +17,7 @@ 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 @@ 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 0e9dd13ffe19c5..e959ea32453cf1 100644 --- a/homeassistant/components/romy/vacuum.py +++ b/homeassistant/components/romy/vacuum.py @@ -11,12 +11,11 @@ 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 @@ 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 f811a2afe03b8a..e8adc9d787ab66 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -9,7 +9,6 @@ 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.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_disconnect_roomba(event): 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 ba362914b6d4f6..b4c5765f53a34b 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 b7d259e3131ac2..49a665c42bda21 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -11,12 +11,7 @@ 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 +26,7 @@ DOMAIN, ROOMBA_SESSION, ) +from .models import RoombaConfigEntry ROOMBA_DISCOVERY_LOCK = "roomba_discovery_lock" ALL_ATTEMPTS = 2 @@ -90,7 +86,7 @@ def __init__(self) -> None: @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 +336,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/models.py b/homeassistant/components/roomba/models.py index 350495cae7bc00..999be8fd514474 100644 --- a/homeassistant/components/roomba/models.py +++ b/homeassistant/components/roomba/models.py @@ -6,6 +6,10 @@ 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 67c33698ff1da2..6aa05b8af309e9 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -11,15 +11,13 @@ 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 @@ class RoombaSensorEntityDescription(SensorEntityDescription): 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 6abc1d52398c48..f2c5a91c4c9f30 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -12,16 +12,14 @@ 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 +85,11 @@ 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 462437df449fc5..4b5226bf260b51 100644 --- a/homeassistant/components/roon/__init__.py +++ b/homeassistant/components/roon/__init__.py @@ -10,6 +10,8 @@ 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 b2a491c8d28f4e..c18a67613b55d0 100644 --- a/homeassistant/components/roon/event.py +++ b/homeassistant/components/roon/event.py @@ -4,12 +4,12 @@ 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 @@ 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 804fb0244b5934..8a4603a6b26bfe 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -15,7 +15,6 @@ 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 +26,7 @@ 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 +45,11 @@ 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/route_b_smart_meter/config_flow.py b/homeassistant/components/route_b_smart_meter/config_flow.py index 1cbeeab4c4e607..2d436f3c978fac 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 @@ 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 @@ def _get_discovered_device_id_and_name( ) 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 6364dbb18d482d..36ff3ed6a209ee 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 f6123b6e4c916e..7e8f13e05a8840 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 ecde0578772b48..dc78ec6610419a 100644 --- a/homeassistant/components/rova/__init__.py +++ b/homeassistant/components/rova/__init__.py @@ -5,19 +5,18 @@ 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 +49,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 a48048d32c38c6..4240d4f3a4656d 100644 --- a/homeassistant/components/rova/coordinator.py +++ b/homeassistant/components/rova/coordinator.py @@ -11,16 +11,18 @@ 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 59f9f28f8f5b6e..a14e7016bb020c 100644 --- a/homeassistant/components/rova/sensor.py +++ b/homeassistant/components/rova/sensor.py @@ -9,14 +9,13 @@ 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 +41,11 @@ 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/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py index 8e9219985ce70a..6aad1cf37349aa 100644 --- a/homeassistant/components/ruckus_unleashed/__init__.py +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -5,7 +5,6 @@ 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 @@ 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 0743b19bdaf7dc..bfbcac68412061 100644 --- a/homeassistant/components/ruckus_unleashed/config_flow.py +++ b/homeassistant/components/ruckus_unleashed/config_flow.py @@ -1,23 +1,37 @@ """Config flow for Ruckus integration.""" +from __future__ import annotations + 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 +77,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 +109,10 @@ async def async_step_user( 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 +130,59 @@ async def async_step_reauth( 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 1aae3041e7332d..7262792b96ff57 100644 --- a/homeassistant/components/ruckus_unleashed/const.py +++ b/homeassistant/components/ruckus_unleashed/const.py @@ -6,6 +6,8 @@ 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 7ffaab2e977776..860d035bed604d 100644 --- a/homeassistant/components/ruckus_unleashed/coordinator.py +++ b/homeassistant/components/ruckus_unleashed/coordinator.py @@ -13,16 +13,21 @@ 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 @@ async def _fetch_clients(self) -> dict: _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 890148ec25cc4d..415c311dea04a8 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -5,52 +5,46 @@ 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 +52,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 +67,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 068c8610dfc4de..29b9e8278f0dfa 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 ddaa83632df773..e328372f242c9c 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 _connection_update_callback( 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 edf542b5de2561..8a1ce320644442 100644 --- a/homeassistant/components/russound_rio/config_flow.py +++ b/homeassistant/components/russound_rio/config_flow.py @@ -2,10 +2,16 @@ 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 +19,104 @@ 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, +) + +TRANSPORT_SCHEMA = vol.Schema( + { + vol.Required(CONF_TYPE, default=TYPE_TCP): SelectSelector( + SelectSelectorConfig( + options=[TYPE_TCP, TYPE_SERIAL], + translation_key="connection_type", + ) + ), + } +) -DATA_SCHEMA = vol.Schema( +TCP_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=9621): cv.port, + 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 +124,16 @@ async def async_step_zeroconf( 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 +149,11 @@ async def async_step_discovery_confirm( 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 +167,71 @@ async def async_step_discovery_confirm( 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 7a8c0bb4fbcf0e..cbe875a524dc03 100644 --- a/homeassistant/components/russound_rio/const.py +++ b/homeassistant/components/russound_rio/const.py @@ -16,3 +16,9 @@ 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 1fe6a7876d18e0..3a5a60512504ef 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -4,9 +4,9 @@ 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 @@ def _zone(self) -> ZoneControlSurface: 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 7d4ddc4cf98d2a..e7cf42dc5848b0 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 588f13960366fb..64cf366ca6e9f0 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 49cd8dae9c47bc..a174b7319f199e 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 @@ 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 a09c663a9837a4..4dff8580f8ba1f 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -7,9 +7,9 @@ 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 ae13815fa0a262..4027a49964b35b 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 00000000000000..486a0cd06f7245 --- /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 7fdc3cdc7afdad..c2910e51e1a16a 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 20ee82ebb5bc63..7e545d4d7bc719 100644 --- a/homeassistant/components/russound_rio/switch.py +++ b/homeassistant/components/russound_rio/switch.py @@ -4,7 +4,7 @@ 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 58925b4b1ffff4..53fb8d46713b0f 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 48808930d9f446..0729cee2fe4c2b 100644 --- a/homeassistant/components/russound_rnet/media_player.py +++ b/homeassistant/components/russound_rnet/media_player.py @@ -2,10 +2,16 @@ 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 +20,14 @@ 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 +37,13 @@ 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 +59,45 @@ } ) +# 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) - - if host is None or port is None: - _LOGGER.error("Invalid config. Expected %s and %s", CONF_HOST, CONF_PORT) - return - - russ = russound.Russound(host, port) - russ.connect() - - sources = [source["name"] for source in config[CONF_SOURCES]] - - 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) + host = config[CONF_HOST] + port = config[CONF_PORT] + + 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 + + sources = [source[CONF_NAME] for source in config[CONF_SOURCES]] + lock = asyncio.Lock() + + async def _async_disconnect(*_: Any) -> None: + """Disconnect the RNET client on HA shutdown.""" + with contextlib.suppress(*RNET_EXCEPTIONS): + await client.disconnect() + + 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 +111,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) - - _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, + 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 + + 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 b82ef6f464321b..8d15f1c2e9499d 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/ruuvitag_ble/sensor.py b/homeassistant/components/ruuvitag_ble/sensor.py index 0b359a570eb753..79faa334f00559 100644 --- a/homeassistant/components/ruuvitag_ble/sensor.py +++ b/homeassistant/components/ruuvitag_ble/sensor.py @@ -198,6 +198,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 20d208cca69604..c16e9e0799dc56 100644 --- a/homeassistant/components/rympro/__init__.py +++ b/homeassistant/components/rympro/__init__.py @@ -6,20 +6,18 @@ 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 +39,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/coordinator.py b/homeassistant/components/rympro/coordinator.py index 6b49a065d35b7a..407210b7587fae 100644 --- a/homeassistant/components/rympro/coordinator.py +++ b/homeassistant/components/rympro/coordinator.py @@ -13,6 +13,8 @@ from .const import DOMAIN +type RymProConfigEntry = ConfigEntry[RymProDataUpdateCoordinator] + SCAN_INTERVAL = 60 * 60 _LOGGER = logging.getLogger(__name__) @@ -21,10 +23,10 @@ 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 66ed41a4ce981d..59df04620bc2f9 100644 --- a/homeassistant/components/rympro/sensor.py +++ b/homeassistant/components/rympro/sensor.py @@ -10,7 +10,6 @@ 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 +17,7 @@ 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 +60,11 @@ class RymProSensorEntityDescription(SensorEntityDescription): 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/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index 180e412a4dbec4..90896a0011a83c 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/sanix/__init__.py b/homeassistant/components/sanix/__init__.py index 60cc5b56f2e1b4..59984601768fff 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 64d28fa91911c7..804421dfe594af 100644 --- a/homeassistant/components/sanix/coordinator.py +++ b/homeassistant/components/sanix/coordinator.py @@ -15,14 +15,16 @@ _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 d2a1aecb099020..81531f111a98b7 100644 --- a/homeassistant/components/sanix/sensor.py +++ b/homeassistant/components/sanix/sensor.py @@ -20,7 +20,6 @@ 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.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 @@ class SanixSensorEntityDescription(SensorEntityDescription): 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 4c695a265618c9..3c75c00eb2d856 100644 --- a/homeassistant/components/satel_integra/__init__.py +++ b/homeassistant/components/satel_integra/__init__.py @@ -9,6 +9,7 @@ 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 @@ def migrate_unique_id(entity_entry: RegistryEntry) -> dict[str, str]: 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 36258155a51159..c9946e5a00d26b 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -105,13 +105,8 @@ def _handle_coordinator_update(self) -> None: 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/client.py b/homeassistant/components/satel_integra/client.py index db66d8af6fa9f2..d9124f1b0d5517 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 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: 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 @@ async def async_connect( 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 b70beaf6ba9c1f..0736b3cb7ef4e5 100644 --- a/homeassistant/components/satel_integra/config_flow.py +++ b/homeassistant/components/satel_integra/config_flow.py @@ -6,11 +6,17 @@ 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 from homeassistant.config_entries import ( ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, @@ -23,6 +29,7 @@ from .const import ( CONF_ARM_HOME_MODE, + CONF_ENCRYPTION_KEY, CONF_OUTPUT_NUMBER, CONF_PARTITION_NUMBER, CONF_SWITCHABLE_OUTPUT_NUMBER, @@ -44,6 +51,9 @@ { 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) + ), } ) @@ -90,7 +100,7 @@ def __init__(self) -> None: self.connection_data: dict[str, Any] = {} VERSION = 2 - MINOR_VERSION = 1 + MINOR_VERSION = 2 @staticmethod @callback @@ -122,15 +132,20 @@ async def async_step_user( 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, @@ -163,19 +178,30 @@ async def async_step_reconfigure( 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]): + # 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 != normalized_input + ): + 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], - reload_even_if_entry_is_unchanged=False, + title=normalized_input[CONF_HOST], ) - errors["base"] = "cannot_connect" - suggested_values: dict[str, Any] = { **reconfigure_entry.data, **(user_input or {}), @@ -189,22 +215,33 @@ async def async_step_reconfigure( 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 33e9c7a9572bdc..a444bff9aca6c7 100644 --- a/homeassistant/components/satel_integra/const.py +++ b/homeassistant/components/satel_integra/const.py @@ -17,3 +17,4 @@ 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 19101ba3ec43b7..8d077753a905d6 100644 --- a/homeassistant/components/satel_integra/coordinator.py +++ b/homeassistant/components/satel_integra/coordinator.py @@ -50,6 +50,17 @@ def __init__( 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 93e9bd104ee6d8..d86ed32a5eb5ad 100644 --- a/homeassistant/components/satel_integra/diagnostics.py +++ b/homeassistant/components/satel_integra/diagnostics.py @@ -9,7 +9,9 @@ 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 +20,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 ac8e391aa9608b..041404289aaaae 100644 --- a/homeassistant/components/satel_integra/entity.py +++ b/homeassistant/components/satel_integra/entity.py @@ -65,3 +65,8 @@ def __init__( 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 79a56a68ce5670..1520a7c87bafcf 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.2.1"] + "requirements": ["satel-integra==1.3.1"] } diff --git a/homeassistant/components/satel_integra/strings.json b/homeassistant/components/satel_integra/strings.json index 67fe3b94101e47..53d64e4f9e6ecb 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/schedule/conditions.yaml b/homeassistant/components/schedule/conditions.yaml index d9d89d329323ba..342c54d023801c 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 b8d3581a696d16..00abdc22f49894 100644 --- a/homeassistant/components/schedule/strings.json +++ b/homeassistant/components/schedule/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_off": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::schedule::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::schedule::common::condition_for_name%]" } }, "name": "Schedule is off" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::schedule::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::schedule::common::condition_for_name%]" } }, "name": "Schedule is on" @@ -44,21 +52,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "get_schedule": { "description": "Retrieves the configured time ranges of one or multiple schedules.", @@ -76,6 +69,9 @@ "fields": { "behavior": { "name": "[%key:component::schedule::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::schedule::common::trigger_for_name%]" } }, "name": "Schedule block ended" @@ -85,6 +81,9 @@ "fields": { "behavior": { "name": "[%key:component::schedule::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::schedule::common::trigger_for_name%]" } }, "name": "Schedule block started" diff --git a/homeassistant/components/schedule/triggers.yaml b/homeassistant/components/schedule/triggers.yaml index e05c515b40133e..247b0fc05e0692 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 ed995d4aa3d4a7..c9dcf2d09afe51 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -37,6 +37,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: schema={ vol.Required("name"): cv.string, vol.Required("code"): 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/lock.py b/homeassistant/components/schlage/lock.py index 739e5a0b1d70c1..a7699d9004c72e 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -113,14 +113,14 @@ async def _async_fetch_access_codes(self) -> dict[str, AccessCode] | None: ) 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/services.yaml b/homeassistant/components/schlage/services.yaml index 97412251ea43c4..e248e8c6e8d2ad 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 48f0232eb751af..0e19b66cf82036 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/schluter/climate.py b/homeassistant/components/schluter/climate.py index 94eb00fe11b98f..58d159bb06e3e7 100644 --- a/homeassistant/components/schluter/climate.py +++ b/homeassistant/components/schluter/climate.py @@ -62,6 +62,7 @@ async def async_update_data(): 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 5c39b57f785a9a..dc234c13766039 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -4,27 +4,40 @@ 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 +45,22 @@ ) 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 +127,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 +147,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 768416aca3e8af..7b5bd1b83f51ea 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -2,12 +2,13 @@ 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 +19,20 @@ ) 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 +44,6 @@ CONF_PAYLOAD, CONF_RESOURCE, CONF_TIMEOUT, - CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, @@ -42,15 +52,7 @@ 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 +71,8 @@ from . import COMBINED_SCHEMA from .const import ( + CONF_ADVANCED, + CONF_AUTH, CONF_ENCODING, CONF_INDEX, CONF_SELECT, @@ -78,243 +82,242 @@ 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, - ) - ), -} +RESOURCE_SETUP = vol.Schema( + { + 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), + ), + } +) + +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) async def validate_rest_setup( - handler: SchemaCommonFlowHandler, user_input: dict[str, Any] + hass: HomeAssistant, user_input: dict[str, Any] ) -> dict[str, Any]: """Validate rest setup.""" - hass = async_get_hass() - rest_config: dict[str, Any] = COMBINED_SCHEMA(user_input) + 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 as err: - raise SchemaFlowError("resource_error") from err + except Exception: + _LOGGER.exception("Error when getting resource %s", config[CONF_RESOURCE]) + return {"base": "resource_error"} 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 {"base": "no_data"} 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]) - }, - ) - } - ) +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, + ) -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]) +class ScrapeSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow.""" -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 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 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 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 ) - } - ) - - -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( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(), - **SENSOR_SETUP, - } -) - -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, - ), -} - - -class ScrapeConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): - """Handle a config flow for Scrape.""" - - 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]) + 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 292f0d0b247ba2..6541d7fe5f8057 100644 --- a/homeassistant/components/scrape/const.py +++ b/homeassistant/components/scrape/const.py @@ -14,6 +14,8 @@ PLATFORMS = [Platform.SENSOR] +CONF_ADVANCED = "advanced" +CONF_AUTH = "auth" CONF_ENCODING = "encoding" CONF_SELECT = "select" CONF_INDEX = "index" diff --git a/homeassistant/components/scrape/icons.json b/homeassistant/components/scrape/icons.json new file mode 100644 index 00000000000000..dfe38ca34cc406 --- /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 c6682fba5a8ed4..7ccbba6e88fb0a 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -46,9 +46,10 @@ 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 +71,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 +99,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 +125,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 4aeae3ce685209..8ffc5d0606b3c3 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" + } } } } }, - "options": { - "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%]" - } + "config_subentries": { + "entity": { + "entry_type": "Sensor", + "initiate_flow": { + "user": "Add sensor" }, - "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%]" + "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": { "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 b4deb9b36aa2e9..36ad1c4e08d88e 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -206,6 +206,8 @@ async def async_step_init(self, user_input=None) -> ConfigFlowResult: 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 5542b7bf611fb1..b650235b8b8a1c 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -65,7 +65,6 @@ 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 +90,6 @@ 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 +768,14 @@ async def async_added_to_hass(self) -> None: 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/select/conditions.yaml b/homeassistant/components/select/conditions.yaml index 18ff8c47c0c70f..d6719808a9928f 100644 --- a/homeassistant/components/select/conditions.yaml +++ b/homeassistant/components/select/conditions.yaml @@ -8,11 +8,13 @@ is_option_selected: required: true default: any selector: - select: - translation_key: condition_behavior - options: - - all - - any + automation_behavior: + mode: condition + for: + required: true + default: 00:00:00 + selector: + duration: option: context: filter_target: target diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index 77f6d51a7fb7cc..2a2c894eca7dcd 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -6,6 +6,9 @@ "behavior": { "name": "Condition passes if" }, + "for": { + "name": "For at least" + }, "option": { "description": "The options to check for.", "name": "Option" @@ -51,14 +54,6 @@ "message": "Option {option} is not valid for entity {entity_id}, valid options are: {options}." } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - } - }, "services": { "select_first": { "description": "Selects the first option of a select.", diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 3816a8c4ff91d9..07187066dcde43 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/sensirion_ble/sensor.py b/homeassistant/components/sensirion_ble/sensor.py index 16f7571f392225..dda968c0d84102 100644 --- a/homeassistant/components/sensirion_ble/sensor.py +++ b/homeassistant/components/sensirion_ble/sensor.py @@ -109,6 +109,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 3148b0d13c2acd..73ea6db5036536 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -3,7 +3,7 @@ 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 +32,7 @@ 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 +64,8 @@ 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 +183,9 @@ def _calculate_precision_from_ratio( 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 +207,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 +629,14 @@ def state(self) -> Any: # 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 +650,13 @@ def state(self) -> Any: 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 0a7fac2157657d..85dd700e5ef1ad 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -60,6 +60,7 @@ ElectricPotentialConverter, EnergyConverter, EnergyDistanceConverter, + FrequencyConverter, InformationConverter, MassConverter, MassVolumeConcentrationConverter, @@ -116,6 +117,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 +195,7 @@ class SensorDeviceClass(StrEnum): CURRENT = "current" """Current. - Unit of measurement: `A`, `mA` + Unit of measurement: `A`, `mA`, `μA` """ DATA_RATE = "data_rate" @@ -238,7 +253,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 +530,7 @@ class SensorDeviceClass(StrEnum): SensorDeviceClass.DATE, SensorDeviceClass.ENUM, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, } DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorDeviceClass)) @@ -565,6 +581,7 @@ class SensorStateClass(StrEnum): 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 +831,7 @@ class SensorStateClass(StrEnum): 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/helpers.py b/homeassistant/components/sensor/helpers.py index 12a5dcefdf8d0b..c404c697da003f 100644 --- a/homeassistant/components/sensor/helpers.py +++ b/homeassistant/components/sensor/helpers.py @@ -18,7 +18,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 59d57da2803461..966e19439e38b8 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/strings.json b/homeassistant/components/sensor/strings.json index 6f8ef1ae530a16..e51c139e8dea87 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/sensorpro/sensor.py b/homeassistant/components/sensorpro/sensor.py index 997fa0db995298..037d613625f31b 100644 --- a/homeassistant/components/sensorpro/sensor.py +++ b/homeassistant/components/sensorpro/sensor.py @@ -114,6 +114,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/serial/manifest.json b/homeassistant/components/serial/manifest.json index 2a5d3c787371cc..b7296c584c96b8 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.0"] } diff --git a/homeassistant/components/serial/sensor.py b/homeassistant/components/serial/sensor.py index f4bfea72cb8061..d3501be641e9d8 100644 --- a/homeassistant/components/serial/sensor.py +++ b/homeassistant/components/serial/sensor.py @@ -3,11 +3,11 @@ 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 +18,7 @@ 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 +34,9 @@ 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 +47,21 @@ 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 +78,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 +102,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 +123,12 @@ def __init__( 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 +138,31 @@ async def async_added_to_hass(self) -> None: 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 +173,7 @@ async def serial_read( 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 +184,15 @@ async def serial_read( _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 +210,10 @@ async def serial_read( _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/seventeentrack/__init__.py b/homeassistant/components/seventeentrack/__init__.py index afb538c6b3257e..6a3e83e7bb230b 100644 --- a/homeassistant/components/seventeentrack/__init__.py +++ b/homeassistant/components/seventeentrack/__init__.py @@ -3,7 +3,6 @@ from pyseventeentrack import Client as SeventeenTrackClient from pyseventeentrack.errors import SeventeenTrackError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -12,7 +11,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .coordinator import SeventeenTrackCoordinator +from .coordinator import SeventeenTrackConfigEntry, SeventeenTrackCoordinator from .services import async_setup_services PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -28,7 +27,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: SeventeenTrackConfigEntry +) -> bool: """Set up 17Track from a config entry.""" session = async_create_clientsession(hass) @@ -43,6 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await seventeen_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = seventeen_coordinator + entry.runtime_data = seventeen_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/seventeentrack/config_flow.py b/homeassistant/components/seventeentrack/config_flow.py index 58cffbb1303b8f..2b0d9f7f68a169 100644 --- a/homeassistant/components/seventeentrack/config_flow.py +++ b/homeassistant/components/seventeentrack/config_flow.py @@ -9,7 +9,7 @@ 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 +25,7 @@ DEFAULT_SHOW_DELIVERED, DOMAIN, ) +from .coordinator import SeventeenTrackConfigEntry CONF_SHOW = { vol.Optional(CONF_SHOW_ARCHIVED, default=DEFAULT_SHOW_ARCHIVED): bool, @@ -54,7 +55,7 @@ class SeventeenTrackConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: SeventeenTrackConfigEntry, ) -> SchemaOptionsFlowHandler: """Get options flow for this handler.""" return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/seventeentrack/coordinator.py b/homeassistant/components/seventeentrack/coordinator.py index 107f1d48a218a5..39a42727c51922 100644 --- a/homeassistant/components/seventeentrack/coordinator.py +++ b/homeassistant/components/seventeentrack/coordinator.py @@ -20,6 +20,8 @@ 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 1064296fa61dca..e4080c43a5e70e 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 c6fd79426557fb..b0b91c3c8dae12 100644 --- a/homeassistant/components/seventeentrack/sensor.py +++ b/homeassistant/components/seventeentrack/sensor.py @@ -3,25 +3,24 @@ 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 62a12b9ddcf8ea..e0cc3909678622 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -16,7 +16,6 @@ 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 @@ 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 1a717e82d824b8..03e903864db4b3 100644 --- a/homeassistant/components/sfr_box/__init__.py +++ b/homeassistant/components/sfr_box/__init__.py @@ -3,7 +3,6 @@ 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 +13,14 @@ 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 +37,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 +51,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 +87,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 bcd0fd71d8f256..10fc6daac90552 100644 --- a/homeassistant/components/sfr_box/binary_sensor.py +++ b/homeassistant/components/sfr_box/binary_sensor.py @@ -4,9 +4,8 @@ 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 +48,26 @@ class SFRBoxBinarySensorEntityDescription[_T](BinarySensorEntityDescription): 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 +87,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 350f72c68acb6e..5ca2350e61993b 100644 --- a/homeassistant/components/sfr_box/button.py +++ b/homeassistant/components/sfr_box/button.py @@ -5,7 +5,7 @@ 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 +78,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/const.py b/homeassistant/components/sfr_box/const.py index acc4e8e494175f..69195289034a15 100644 --- a/homeassistant/components/sfr_box/const.py +++ b/homeassistant/components/sfr_box/const.py @@ -7,5 +7,4 @@ 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 9b131177e37668..cd9f8914e20ce5 100644 --- a/homeassistant/components/sfr_box/coordinator.py +++ b/homeassistant/components/sfr_box/coordinator.py @@ -10,7 +10,7 @@ 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 +29,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/icons.json b/homeassistant/components/sfr_box/icons.json new file mode 100644 index 00000000000000..a24499e0ae3240 --- /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/manifest.json b/homeassistant/components/sfr_box/manifest.json index afea6a999fe444..534f6996abbb11 100644 --- a/homeassistant/components/sfr_box/manifest.json +++ b/homeassistant/components/sfr_box/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["sfrbox-api==0.1.0"] + "requirements": ["sfrbox-api==0.1.1"] } diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py index 88477903687e14..4884886854c574 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 @@ class SFRBoxSensorEntityDescription[_T](SensorEntityDescription): 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 52ba0b295cde62..dbf02e369cced4 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/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py index 4fc53614fa2a4b..4a903cf378654d 100644 --- a/homeassistant/components/sharkiq/__init__.py +++ b/homeassistant/components/sharkiq/__init__.py @@ -13,7 +13,6 @@ ) 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 @@ 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/coordinator.py b/homeassistant/components/sharkiq/coordinator.py index 1a4a819cdf6bb2..dd82e3758c06a8 100644 --- a/homeassistant/components/sharkiq/coordinator.py +++ b/homeassistant/components/sharkiq/coordinator.py @@ -20,16 +20,18 @@ 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/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 3856bf73554fb1..6ccc95f29c2795 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -12,7 +12,6 @@ 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 +19,7 @@ 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 +45,11 @@ 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 842dc74ea5a68d..8dbd867a389304 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -3,12 +3,16 @@ 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 +20,12 @@ 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 +40,14 @@ ) -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 +73,6 @@ async def async_service_handler(service: ServiceCall) -> ServiceResponse: if rendered_args == args: # No template used. default behavior - create_process = asyncio.create_subprocess_shell( cmd, stdin=None, @@ -78,7 +84,6 @@ async def async_service_handler(service: ServiceCall) -> ServiceResponse: # 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 +158,81 @@ async def async_service_handler(service: ServiceCall) -> ServiceResponse: 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 00000000000000..a9829425570a0c --- /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 df056f94e85fa0..c983a105c93977 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 f2f2dc1b819dd9..a395ef9bd52631 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 2120f5e50e6328..b47aa78cb08359 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -84,6 +84,7 @@ Platform.COVER, Platform.EVENT, Platform.LIGHT, + Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, @@ -117,6 +118,8 @@ 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 632e5277de5f2e..51bf7786233b72 100644 --- a/homeassistant/components/shelly/binary_sensor.py +++ b/homeassistant/components/shelly/binary_sensor.py @@ -350,6 +350,28 @@ def __init__( 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/config_flow.py b/homeassistant/components/shelly/config_flow.py index 98dcab1be7bcd3..58859f0978f254 100644 --- a/homeassistant/components/shelly/config_flow.py +++ b/homeassistant/components/shelly/config_flow.py @@ -807,7 +807,7 @@ async def async_step_wifi_scan( ) 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 +1086,7 @@ async def async_step_provision_failed( ) -> 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 bfb399dc5b97f6..6c755959faf377 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -26,6 +26,7 @@ MODEL_VINTAGE_V2, MODEL_WALL_DISPLAY, MODEL_WALL_DISPLAY_X2, + MODEL_WALL_DISPLAY_X2I, MODEL_WALL_DISPLAY_XL, ) @@ -218,8 +219,6 @@ 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 +226,7 @@ SHELLY_WALL_DISPLAY_MODELS = ( MODEL_WALL_DISPLAY, MODEL_WALL_DISPLAY_X2, + MODEL_WALL_DISPLAY_X2I, MODEL_WALL_DISPLAY_XL, ) @@ -289,10 +289,8 @@ class DeprecatedFirmwareInfo(TypedDict): 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/entity.py b/homeassistant/components/shelly/entity.py index 9540f2560f3bf3..073aa3f4b59baf 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -74,7 +74,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/manifest.json b/homeassistant/components/shelly/manifest.json index 7400cc5cf06f75..4cd4082a2e53e9 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 00000000000000..8479f2e72e779c --- /dev/null +++ b/homeassistant/components/shelly/media_player.py @@ -0,0 +1,430 @@ +"""Media player for Shelly.""" + +from __future__ import annotations + +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/sensor.py b/homeassistant/components/shelly/sensor.py index 5eeb818c59a56d..9e0ddf49a9e7f6 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -3,6 +3,7 @@ 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 +42,7 @@ 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 +64,6 @@ 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 +467,8 @@ def __init__( ), "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 +1242,8 @@ def __init__( "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 b61ce0af7724f9..143c4827943a75 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/utils.py b/homeassistant/components/shelly/utils.py index 27afa335e5e478..7d26eee7c74b8f 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -3,7 +3,6 @@ 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 +50,6 @@ DeviceInfo, ) from homeassistant.helpers.network import NoURLAvailableError, get_url -from homeassistant.util.dt import utcnow from .const import ( API_WS_URL, @@ -76,10 +74,11 @@ 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 +119,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 +191,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 +225,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 +566,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/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index e60acf4b377853..cf5900e2096773 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -2,11 +2,9 @@ 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 +12,26 @@ 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 +44,9 @@ 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 +61,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 +105,19 @@ async def remove_item_service(call: ServiceCall) -> None: 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 +125,7 @@ async def incomplete_item_service(call: ServiceCall) -> None: 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 +143,7 @@ async def sort_list_service(call: ServiceCall) -> None: """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 +203,6 @@ async def sort_list_service(call: ServiceCall) -> None: 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 +212,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 +224,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 +244,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 +257,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 +271,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 +285,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 +306,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 +339,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 +359,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 +379,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 00000000000000..bd2ecbe92ef4a2 --- /dev/null +++ b/homeassistant/components/shopping_list/common.py @@ -0,0 +1,281 @@ +"""Shopping list commons.""" + +from __future__ import annotations + +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/intent.py b/homeassistant/components/shopping_list/intent.py index 06bb692621ad16..04fc3884ec4cdc 100644 --- a/homeassistant/components/shopping_list/intent.py +++ b/homeassistant/components/shopping_list/intent.py @@ -5,7 +5,8 @@ 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 +32,7 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse """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 +53,9 @@ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse 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 +77,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 7826f06618a608..06ffc307b1a7d9 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 2952c28308230b..61b9c0b8048297 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -8,22 +8,20 @@ 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 d1bc3fa99683ac..215228623941d0 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/config_flow.py b/homeassistant/components/sia/config_flow.py index a23978145e72d1..16ba3103a753bf 100644 --- a/homeassistant/components/sia/config_flow.py +++ b/homeassistant/components/sia/config_flow.py @@ -16,12 +16,7 @@ ) 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 +31,7 @@ DOMAIN, TITLE, ) -from .hub import SIAHub +from .hub import SIAConfigEntry, SIAHub _LOGGER = logging.getLogger(__name__) @@ -100,7 +95,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 +174,9 @@ def _update_data(self, user_input: dict[str, Any]) -> None: 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 +186,7 @@ async def async_step_init( 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/hub.py b/homeassistant/components/sia/hub.py index 591e4aadad76ee..6c0bf80c64c6f6 100644 --- a/homeassistant/components/sia/hub.py +++ b/homeassistant/components/sia/hub.py @@ -8,7 +8,7 @@ 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 +28,8 @@ _LOGGER = logging.getLogger(__name__) +type SIAConfigEntry = ConfigEntry[SIAHub] + DEFAULT_TIMEBAND = (80, 40) @@ -37,11 +39,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 +133,7 @@ def _load_options(self) -> None: @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 +141,8 @@ async def async_config_entry_updated( 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/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index d9ab3e3b4f137f..4c00c7441193d7 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -4,7 +4,7 @@ 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 +39,7 @@ ) 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 +88,8 @@ 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 +225,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 +293,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 +317,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 +402,9 @@ async def async_reload_entry(_: HomeAssistant, updated_entry: ConfigEntry) -> No 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 c5a1b2bc7084b3..31240fdb071a15 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -28,12 +28,11 @@ 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 +43,6 @@ ATTR_EXIT_DELAY_HOME, ATTR_LIGHT, ATTR_VOICE_PROMPT_VOLUME, - DOMAIN, LOGGER, ) from .entity import SimpliSafeEntity @@ -104,11 +102,11 @@ 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 4cd02431148c8b..7de2e8482c71c0 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING, cast + from simplipy.device import DeviceTypes, DeviceV3 from simplipy.device.sensor.v3 import SensorV3 from simplipy.system.v3 import SystemV3 @@ -11,13 +13,12 @@ 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 +60,11 @@ 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 +73,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 129209354c3898..a4888ed8b6d059 100644 --- a/homeassistant/components/simplisafe/button.py +++ b/homeassistant/components/simplisafe/button.py @@ -9,14 +9,12 @@ 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 +45,11 @@ async def _async_clear_notifications(system: System) -> None: 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 6494b84981bd4d..c6c760d099adab 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -14,16 +14,12 @@ ) 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 +64,7 @@ def __init__(self) -> None: @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/diagnostics.py b/homeassistant/components/simplisafe/diagnostics.py index e63e1551740343..7260efeb5afcf7 100644 --- a/homeassistant/components/simplisafe/diagnostics.py +++ b/homeassistant/components/simplisafe/diagnostics.py @@ -5,7 +5,6 @@ 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 +15,7 @@ ) from homeassistant.core import HomeAssistant -from . import SimpliSafe -from .const import DOMAIN +from . import SimpliSafeConfigEntry CONF_CREDIT_CARD = "creditCard" CONF_EXPIRES = "expires" @@ -53,10 +51,10 @@ 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/lock.py b/homeassistant/components/simplisafe/lock.py index a0626898a211cd..90e52a87969a4c 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -2,7 +2,7 @@ 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 +10,12 @@ 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 +31,11 @@ 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 +43,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 b82162f0fe7b39..79bd5b599baa8b 100644 --- a/homeassistant/components/simplisafe/sensor.py +++ b/homeassistant/components/simplisafe/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import TYPE_CHECKING, cast + from simplipy.device import DeviceTypes from simplipy.device.sensor.v3 import SensorV3 from simplipy.system.v3 import SystemV3 @@ -11,23 +13,22 @@ 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 +36,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/siren/conditions.yaml b/homeassistant/components/siren/conditions.yaml index 41145760d92e98..edbf8c6ff34795 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 e20c34217364f5..e28698e5d41de5 100644 --- a/homeassistant/components/siren/strings.json +++ b/homeassistant/components/siren/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_off": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::siren::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::siren::common::condition_for_name%]" } }, "name": "Siren is off" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::siren::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::siren::common::condition_for_name%]" } }, "name": "Siren is on" @@ -37,21 +45,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "toggle": { "description": "Toggles a siren on/off.", @@ -87,6 +80,9 @@ "fields": { "behavior": { "name": "[%key:component::siren::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::siren::common::trigger_for_name%]" } }, "name": "Siren turned off" @@ -96,6 +92,9 @@ "fields": { "behavior": { "name": "[%key:component::siren::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::siren::common::trigger_for_name%]" } }, "name": "Siren turned on" diff --git a/homeassistant/components/siren/triggers.yaml b/homeassistant/components/siren/triggers.yaml index 798b9dcd89774b..13fef66b2f0e83 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/sky_hub/__init__.py b/homeassistant/components/sky_hub/__init__.py index a5b8969018f034..3c465305acdb61 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/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index 5baa4ad83ade83..71ff9dc9788083 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -7,14 +7,12 @@ 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 +23,7 @@ ] -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 +51,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 cc42da48b26d43..cd9c8dd5eebe33 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -9,12 +9,10 @@ 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 +30,14 @@ 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 4ee873f83501ae..6bc285a8cf6e5b 100644 --- a/homeassistant/components/skybell/camera.py +++ b/homeassistant/components/skybell/camera.py @@ -7,14 +7,12 @@ 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 +29,13 @@ 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/coordinator.py b/homeassistant/components/skybell/coordinator.py index 48e67c63ac96c5..499363191f8904 100644 --- a/homeassistant/components/skybell/coordinator.py +++ b/homeassistant/components/skybell/coordinator.py @@ -10,14 +10,19 @@ 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/light.py b/homeassistant/components/skybell/light.py index 3f924f68da89be..dce8040b8ae29f 100644 --- a/homeassistant/components/skybell/light.py +++ b/homeassistant/components/skybell/light.py @@ -13,23 +13,22 @@ 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 a67fdae3b351a4..9674a6af6f56a2 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -14,13 +14,13 @@ 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 +89,13 @@ class SkybellSensorEntityDescription(SensorEntityDescription): 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 858363043ca222..7827aec70fb99c 100644 --- a/homeassistant/components/skybell/switch.py +++ b/homeassistant/components/skybell/switch.py @@ -5,11 +5,10 @@ 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 +29,13 @@ 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 899b46ee7e8393..f2c6926dfcf9d1 100644 --- a/homeassistant/components/slack/__init__.py +++ b/homeassistant/components/slack/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass import logging from aiohttp.client_exceptions import ClientError @@ -30,6 +31,17 @@ 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 +49,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 +64,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/entity.py b/homeassistant/components/slack/entity.py index 30218360054fda..040cb58aa0cb86 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/sensor.py b/homeassistant/components/slack/sensor.py index 042ab00916ecc4..df8517f660ab03 100644 --- a/homeassistant/components/slack/sensor.py +++ b/homeassistant/components/slack/sensor.py @@ -9,25 +9,25 @@ 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/sleepiq/__init__.py b/homeassistant/components/sleepiq/__init__.py index 8eb703b7f5f3ee..235df79b976ef5 100644 --- a/homeassistant/components/sleepiq/__init__.py +++ b/homeassistant/components/sleepiq/__init__.py @@ -23,6 +23,7 @@ from .const import DOMAIN, IS_IN_BED, SLEEP_NUMBER from .coordinator import ( + SleepIQConfigEntry, SleepIQData, SleepIQDataUpdateCoordinator, SleepIQPauseUpdateCoordinator, @@ -64,7 +65,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 +105,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 +117,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 99fff9c49b0c70..501e2a824dc8f3 100644 --- a/homeassistant/components/sleepiq/binary_sensor.py +++ b/homeassistant/components/sleepiq/binary_sensor.py @@ -6,22 +6,21 @@ 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 74b1bc0789f83b..e0dec0d2897a52 100644 --- a/homeassistant/components/sleepiq/button.py +++ b/homeassistant/components/sleepiq/button.py @@ -9,12 +9,10 @@ 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 +41,11 @@ class SleepIQButtonEntityDescription(ButtonEntityDescription): 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/coordinator.py b/homeassistant/components/sleepiq/coordinator.py index 0baeca03fe560d..d15094049cf58e 100644 --- a/homeassistant/components/sleepiq/coordinator.py +++ b/homeassistant/components/sleepiq/coordinator.py @@ -18,16 +18,18 @@ 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 @@ async def _async_update_data(self) -> 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 @@ async def _async_update_data(self) -> 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 542c212df27107..9b273df1ea430d 100644 --- a/homeassistant/components/sleepiq/light.py +++ b/homeassistant/components/sleepiq/light.py @@ -6,12 +6,10 @@ 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 @@ 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 1a99f47c38c6d8..57ccd5457f5d87 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -21,7 +21,6 @@ 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 +28,12 @@ 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 +178,11 @@ def _get_core_climate_unique_id( 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 d4bc9fda3a4358..332e41879070cb 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -13,22 +13,21 @@ ) 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 5d22897d97b314..c1f1d94c98a232 100644 --- a/homeassistant/components/sleepiq/sensor.py +++ b/homeassistant/components/sleepiq/sensor.py @@ -13,13 +13,11 @@ 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 +27,7 @@ SLEEP_SCORE, ) from .coordinator import ( - SleepIQData, + SleepIQConfigEntry, SleepIQDataUpdateCoordinator, SleepIQSleepDataCoordinator, ) @@ -112,11 +110,11 @@ class SleepIQSensorEntityDescription(SensorEntityDescription): 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 8363782c064d2a..283a479d3b0fc6 100644 --- a/homeassistant/components/sleepiq/switch.py +++ b/homeassistant/components/sleepiq/switch.py @@ -7,22 +7,20 @@ 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/slimproto/__init__.py b/homeassistant/components/slimproto/__init__.py index a5ab10ac32b85a..a5194631b2c314 100644 --- a/homeassistant/components/slimproto/__init__.py +++ b/homeassistant/components/slimproto/__init__.py @@ -2,24 +2,24 @@ 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 +37,17 @@ async def on_hass_stop(event: Event) -> None: 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/media_player.py b/homeassistant/components/slimproto/media_player.py index 417444961feb86..c1e1c5d16d385f 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -19,12 +19,12 @@ 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 +38,11 @@ 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/smappee/__init__.py b/homeassistant/components/smappee/__init__.py index 7fa30965aa8f24..55b507e51c146d 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 @@ -11,6 +12,7 @@ CONF_PLATFORM, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle @@ -94,11 +96,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmappeeConfigEntry) -> b ) await hass.async_add_executor_job(smappee.load_local_service_location) else: - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry + try: + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) ) - ) + except config_entry_oauth2_flow.ImplementationUnavailableError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="oauth2_implementation_unavailable", + ) from err smappee_api = api.ConfigEntrySmappeeApi(hass, entry, implementation) diff --git a/homeassistant/components/smappee/api.py b/homeassistant/components/smappee/api.py index 1a036b1072fb8a..6e06845c4976d3 100644 --- a/homeassistant/components/smappee/api.py +++ b/homeassistant/components/smappee/api.py @@ -36,6 +36,8 @@ def __init__( 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/strings.json b/homeassistant/components/smappee/strings.json index d1f333ffcdc0e7..0df1c68ecba0e4 100644 --- a/homeassistant/components/smappee/strings.json +++ b/homeassistant/components/smappee/strings.json @@ -43,5 +43,10 @@ "title": "Discovered Smappee device" } } + }, + "exceptions": { + "oauth2_implementation_unavailable": { + "message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]" + } } } diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py index d55c44824df6a1..5aa7996407082a 100644 --- a/homeassistant/components/smart_meter_texas/__init__.py +++ b/homeassistant/components/smart_meter_texas/__init__.py @@ -5,20 +5,24 @@ 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 defe49f0be4aed..9c499811f104de 100644 --- a/homeassistant/components/smart_meter_texas/const.py +++ b/homeassistant/components/smart_meter_texas/const.py @@ -5,9 +5,6 @@ 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 b489c0db01ed04..b1a26a6ee53475 100644 --- a/homeassistant/components/smart_meter_texas/coordinator.py +++ b/homeassistant/components/smart_meter_texas/coordinator.py @@ -52,15 +52,18 @@ async def read_meters(self) -> list[Meter]: 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 @@ def __init__( 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 ecddd5c80c456d..80318d85d20807 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -9,32 +9,24 @@ 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/icons.json b/homeassistant/components/smartthings/icons.json index 29754f1cbed44d..536a7fba80b599 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/sensor.py b/homeassistant/components/smartthings/sensor.py index 1f303013182b94..c36f6d7b5e204d 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -1286,6 +1286,8 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): 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 6deaefceae49d0..78a34e0339e12d 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 fbf6ebd630f9da..01c1eaaedd14fa 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -80,6 +80,13 @@ class SmartThingsDishwasherWashingOptionSwitchEntityDescription( 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/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index a6d7bbd14ea305..8c949bf6aeecaf 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -18,6 +18,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.INFRARED, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/smlight/infrared.py b/homeassistant/components/smlight/infrared.py new file mode 100644 index 00000000000000..6f6cd185173872 --- /dev/null +++ b/homeassistant/components/smlight/infrared.py @@ -0,0 +1,60 @@ +"""Infrared platform for SLZB-Ultima.""" + +from __future__ import annotations + +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 985799ab0e6c52..e727bf20a34e3d 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/strings.json b/homeassistant/components/smlight/strings.json index 6fbac239207976..31fb16650a94b7 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -79,6 +79,11 @@ "name": "Zigbee restart" } }, + "infrared": { + "infrared_emitter": { + "name": "IR emitter" + } + }, "light": { "ambilight": { "name": "Ambilight" @@ -159,6 +164,9 @@ }, "firmware_update_failed": { "message": "Firmware update failed for {device_name}." + }, + "send_ir_code_failed": { + "message": "Failed to send IR code: {error}." } }, "issues": { diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index 0888f339a7dc3c..e56d28fa7bd190 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.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/coordinator.py b/homeassistant/components/snapcast/coordinator.py index 963f12887fcf2c..15d7c154bd8acd 100644 --- a/homeassistant/components/snapcast/coordinator.py +++ b/homeassistant/components/snapcast/coordinator.py @@ -13,13 +13,15 @@ _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/media_player.py b/homeassistant/components/snapcast/media_player.py index bccded10176a1b..bd73ed70ae8dee 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -17,14 +17,13 @@ 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 +37,12 @@ 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/snmp/__init__.py b/homeassistant/components/snmp/__init__.py index 4a049ee1553558..1da23965dce3f5 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/snooz/__init__.py b/homeassistant/components/snooz/__init__.py index c97c89c2f4add1..c60697cb2f881b 100644 --- a/homeassistant/components/snooz/__init__.py +++ b/homeassistant/components/snooz/__init__.py @@ -7,16 +7,15 @@ 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 +30,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/fan.py b/homeassistant/components/snooz/fan.py index ce804450cab337..52a7dc2cad7976 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -17,7 +17,6 @@ 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 +33,12 @@ 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 +66,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 d1c49fe9dc61a0..0ac7cfd2d99f05 100644 --- a/homeassistant/components/snooz/models.py +++ b/homeassistant/components/snooz/models.py @@ -5,6 +5,10 @@ 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/const.py b/homeassistant/components/solaredge/const.py index 35a14091e68b0f..e2e141402fbeb6 100644 --- a/homeassistant/components/solaredge/const.py +++ b/homeassistant/components/solaredge/const.py @@ -22,6 +22,7 @@ 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 ed3bff8cea2e37..9fb33a755f3810 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -38,6 +38,7 @@ MODULE_STATISTICS_UPDATE_DELAY, OVERVIEW_UPDATE_DELAY, POWER_FLOW_UPDATE_DELAY, + STORAGE_DATA_UPDATE_DELAY, ) if TYPE_CHECKING: @@ -334,6 +335,86 @@ async def async_update_data(self) -> None: 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/manifest.json b/homeassistant/components/solaredge/manifest.json index 295d562778f59a..276ddaf0c821e7 100644 --- a/homeassistant/components/solaredge/manifest.json +++ b/homeassistant/components/solaredge/manifest.json @@ -14,5 +14,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "loggers": ["aiosolaredge", "solaredge_web"], - "requirements": ["aiosolaredge==0.2.0", "solaredge-web==0.0.1"] + "requirements": ["aiosolaredge==1.0.2", "solaredge-web==0.0.1"] } diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index b56c35be16023d..096b1eed70dc69 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -22,7 +22,7 @@ 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 +30,7 @@ SolarEdgeInventoryDataService, SolarEdgeOverviewDataService, SolarEdgePowerFlowDataService, + SolarEdgeStorageDataService, ) from .types import SolarEdgeConfigEntry @@ -207,6 +208,64 @@ class SolarEdgeSensorEntityDescription(SensorEntityDescription): 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 +281,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 +338,17 @@ def __init__( inventory = SolarEdgeInventoryDataService(hass, config_entry, api, site_id) flow = SolarEdgePowerFlowDataService(hass, config_entry, api, site_id) energy = SolarEdgeEnergyDetailsService(hass, config_entry, api, site_id) - - self.all_services = (details, overview, inventory, flow, energy) + storage = SolarEdgeStorageDataService(hass, config_entry, api, site_id) + + self.all_services: list[SolarEdgeDataService] = [ + details, + overview, + inventory, + flow, + energy, + ] + self.inventory_service = inventory + self.storage_service = storage self.services: dict[ str, @@ -289,6 +385,56 @@ def __init__( ): 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 +462,11 @@ def __init__( 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 +574,41 @@ def native_value(self) -> str | None: 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 2dd02f70ade838..0225262e9735d0 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/soma/__init__.py b/homeassistant/components/soma/__init__.py index 127b51338ee065..486c2c05bf9e02 100644 --- a/homeassistant/components/soma/__init__.py +++ b/homeassistant/components/soma/__init__.py @@ -2,6 +2,9 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Any + from api.soma_api import SomaApi import voluptuous as vol @@ -12,7 +15,7 @@ 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 +29,17 @@ 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 +59,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 b34596abe93f57..20f5d60b2c442e 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 15aa21b1f48c94..2e183f3c27b229 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -11,28 +11,27 @@ 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/sensor.py b/homeassistant/components/soma/sensor.py index 839f28e9a65e63..b992d1f8b1d596 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 @@ 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 fdbaaf9f4274fa..4e7028ec6c9eba 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.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: - """Set up Somfy MyLink from a config entry.""" - hass.data.setdefault(DOMAIN, {}) +@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.""" 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 91cfae87347912..fc3cc476933a8e 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -10,7 +10,6 @@ import voluptuous as vol from homeassistant.config_entries import ( - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -22,6 +21,7 @@ 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 +30,6 @@ CONF_TARGET_NAME, DEFAULT_PORT, DOMAIN, - MYLINK_STATUS, ) _LOGGER = logging.getLogger(__name__) @@ -119,7 +118,7 @@ async def async_step_user( @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 +127,9 @@ def async_get_options_flow( 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 +137,7 @@ def __init__(self, config_entry: ConfigEntry) -> None: @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 8669c73fb9b7c3..a4740ba4b55891 100644 --- a/homeassistant/components/somfy_mylink/const.py +++ b/homeassistant/components/somfy_mylink/const.py @@ -10,8 +10,6 @@ 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 5b888ea4b960e3..e731bbac698e1f 100644 --- a/homeassistant/components/somfy_mylink/cover.py +++ b/homeassistant/components/somfy_mylink/cover.py @@ -4,19 +4,13 @@ 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 @@ 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/helpers.py b/homeassistant/components/sonarr/helpers.py index 522009785b1783..e0943139ef4459 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 3aeb4348e6d866..74e172580ef604 100644 --- a/homeassistant/components/sonarr/sensor.py +++ b/homeassistant/components/sonarr/sensor.py @@ -65,9 +65,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 +77,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 +126,8 @@ def get_wanted_attr(wanted: SonarrWantedMissing) -> dict[str, str]: 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/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 6f5a8033620fcf..91d91eedf167a5 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -1,4 +1,5 @@ """Support to embed Sonos.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/sonos/alarms.py b/homeassistant/components/sonos/alarms.py index c318151454e6a0..645531f90f9950 100644 --- a/homeassistant/components/sonos/alarms.py +++ b/homeassistant/components/sonos/alarms.py @@ -6,7 +6,7 @@ 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 +30,7 @@ def __init__(self, *args: Any) -> None: 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 +77,40 @@ async def async_process_event( await self.async_update_entities(speaker.soco, event_id) @soco_error() - def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool: - """Update cache of known alarms and return if cache has changed.""" - self.alarms.update(soco) + def update_cache( + self, + soco: SoCo, + update_id: int | None = None, + ) -> bool: + """Update cache of known alarms and return whether any were seen.""" + try: + self.alarms.update(soco) + except SoCoException as err: + err_msg = str(err) + # Only catch the specific household mismatch error + if "Alarm list UID" in err_msg and "does not match" in err_msg: + if not self._household_mismatch_logged: + _LOGGER.warning( + "Sonos alarms for %s cannot be updated due to a household mismatch. " + "This is a known limitation in setups with multiple households. " + "You can safely ignore this warning, or to silence it, remove the " + "affected household from your Sonos system. Error: %s", + soco.player_name, + err_msg, + ) + self._household_mismatch_logged = True + return False + # Let all other exceptions bubble up to be handled by @soco_error() + raise if update_id and self.alarms.last_id < update_id: # Skip updates if latest query result is outdated or lagging return False - if ( self.last_processed_event_id and self.alarms.last_id <= self.last_processed_event_id ): - # Skip updates already processed return False - _LOGGER.debug( "Updating processed event %s from %s (was %s)", self.alarms.last_id, diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 5f7a2fb2d704b7..144548100a9233 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -115,6 +115,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/services.py b/homeassistant/components/sonos/services.py index 883835a7c86679..cb20483744349c 100644 --- a/homeassistant/components/sonos/services.py +++ b/homeassistant/components/sonos/services.py @@ -8,7 +8,6 @@ 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 +34,11 @@ 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 +50,22 @@ async def async_service_handle(service_call: ServiceCall) -> 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} - ) - - hass.services.async_register( - DOMAIN, SERVICE_SNAPSHOT, async_service_handle, join_unjoin_schema + 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_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 5d596c5679fe25..130d873b6c85d4 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 78a7245ef9f668..c3764de652cb0e 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -165,6 +165,8 @@ def __init__( 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 +940,14 @@ def _async_regroup(group: list[str]) -> None: 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/strings.json b/homeassistant/components/sonos/strings.json index 2362679dc7c091..386dcfb452f8b5 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 653be229b22d90..ee60826b7b7d9b 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -12,6 +12,7 @@ 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 +26,7 @@ SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM, SONOS_CREATE_SWITCHES, + SOURCE_TV, ) from .entity import SonosEntity, SonosPollingEntity from .helpers import SonosConfigEntry, soco_error @@ -49,6 +51,8 @@ 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 +76,8 @@ 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 +125,52 @@ def available_soco_attributes(speaker: SonosSpeaker) -> list[str]: 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 +189,31 @@ async def _async_create_switches(speaker: SonosSpeaker) -> None: 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 +285,135 @@ def send_command(self, enable: bool) -> None: _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/soundtouch/__init__.py b/homeassistant/components/soundtouch/__init__.py index bb11ebfaa195cc..c7a618b4c4a8af 100644 --- a/homeassistant/components/soundtouch/__init__.py +++ b/homeassistant/components/soundtouch/__init__.py @@ -1,6 +1,9 @@ """The soundtouch component.""" +from __future__ import annotations + import logging +from typing import TYPE_CHECKING from libsoundtouch import soundtouch_device from libsoundtouch.device import SoundTouchDevice @@ -22,6 +25,11 @@ 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 +58,12 @@ 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 +73,25 @@ async def service_handle(service: ServiceCall) -> None: """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 +103,9 @@ async def service_handle(service: ServiceCall) -> None: 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 +143,7 @@ async def service_handle(service: ServiceCall) -> None: 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 +154,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 02c0d8a1bbf9ff..7deefe97363cfc 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -19,7 +19,6 @@ 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 +28,7 @@ ) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import SoundTouchConfigEntry from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -46,16 +46,16 @@ 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 +388,16 @@ def get_zone_info(self): 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 7460cc5dcdf7b7..106ce1b87192cc 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/sql/manifest.json b/homeassistant/components/sql/manifest.json index 44ee32ec8e8c65..01a2448526cda1 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/util.py b/homeassistant/components/sql/util.py index 7433462f125a84..b127641524cc79 100644 --- a/homeassistant/components/sql/util.py +++ b/homeassistant/components/sql/util.py @@ -1,4 +1,5 @@ """Utils for sql.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 3ba320091a6818..4383be1eb6dec5 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -22,11 +22,13 @@ 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 _discovered_player(player: Player) -> None: 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/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index c078fc377b5050..2aac7ed15b858e 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -138,3 +138,10 @@ def rediscovered(self, unique_id: str, connected: bool) -> None: _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 094f50397a6002..5757003e3d479e 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -292,10 +292,17 @@ async def async_added_to_hass(self) -> None: 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 0817aead782283..2df336aeca7440 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/srp_energy/__init__.py b/homeassistant/components/srp_energy/__init__.py index 13c21709445f94..0a540638e69e13 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/coordinator.py b/homeassistant/components/srp_energy/coordinator.py index f3821891afa869..c581c5d8686305 100644 --- a/homeassistant/components/srp_energy/coordinator.py +++ b/homeassistant/components/srp_energy/coordinator.py @@ -23,14 +23,19 @@ TIMEOUT = 10 PHOENIX_ZONE_INFO = dt_util.get_time_zone(PHOENIX_TIME_ZONE) +type SRPEnergyConfigEntry = ConfigEntry[SRPEnergyDataUpdateCoordinator] + class SRPEnergyDataUpdateCoordinator(DataUpdateCoordinator[float]): """A srp_energy Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: SRPEnergyConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, client: SrpEnergyClient + self, + hass: HomeAssistant, + config_entry: SRPEnergyConfigEntry, + client: SrpEnergyClient, ) -> None: """Initialize the srp_energy data coordinator.""" self._client = client diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index 89274390411e09..a6148d27fd0a37 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -7,7 +7,6 @@ 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 +14,17 @@ 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 +40,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 97375cb600a720..64f3062c90c807 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -10,7 +10,7 @@ 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 +45,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 +67,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 +75,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 +83,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/starline/__init__.py b/homeassistant/components/starline/__init__.py index 17f3b7dc504345..21b790ea1188b9 100644 --- a/homeassistant/components/starline/__init__.py +++ b/homeassistant/components/starline/__init__.py @@ -22,8 +22,10 @@ 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 +33,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 +92,23 @@ async def async_update(call: ServiceCall | None = None) -> None: 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/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py index faec8974ed1ca2..d9452e8c0dee4a 100644 --- a/homeassistant/components/starline/binary_sensor.py +++ b/homeassistant/components/starline/binary_sensor.py @@ -7,13 +7,12 @@ 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 +70,11 @@ 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 fd449607f5293a..4bc06f41240ba7 100644 --- a/homeassistant/components/starline/button.py +++ b/homeassistant/components/starline/button.py @@ -3,12 +3,11 @@ 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 +34,11 @@ 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/device_tracker.py b/homeassistant/components/starline/device_tracker.py index d6e12b4ecd9154..cb9444d579a3bc 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/lock.py b/homeassistant/components/starline/lock.py index 43886d63962515..19329090abe2ec 100644 --- a/homeassistant/components/starline/lock.py +++ b/homeassistant/components/starline/lock.py @@ -5,22 +5,21 @@ 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 5fff61144dc3a7..ef513d6b4ddf6e 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -10,7 +10,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -23,8 +22,8 @@ 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 +90,11 @@ 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 3a457c6ffdee45..b21bdb4a777e67 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -5,12 +5,11 @@ 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 +34,11 @@ 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/startca/__init__.py b/homeassistant/components/startca/__init__.py index aca4a424a36cf4..fe2ab1fd151563 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 add795cea92256..deec32097766a5 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/steamist/discovery.py b/homeassistant/components/steamist/discovery.py index 2abe2343f99e2a..82a636a649663b 100644 --- a/homeassistant/components/steamist/discovery.py +++ b/homeassistant/components/steamist/discovery.py @@ -114,6 +114,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/sensor.py b/homeassistant/components/steamist/sensor.py index 94e3ff86ee1422..c14856a1cb56f7 100644 --- a/homeassistant/components/steamist/sensor.py +++ b/homeassistant/components/steamist/sensor.py @@ -61,6 +61,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 17e1d6d47ac992..a14a6ed6cea60a 100644 --- a/homeassistant/components/steamist/switch.py +++ b/homeassistant/components/steamist/switch.py @@ -25,6 +25,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/stream/manifest.json b/homeassistant/components/stream/manifest.json index 6d2ca7865f9ae0..b47763a3ab2938 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/worker.py b/homeassistant/components/stream/worker.py index f2d59c7e09050e..4c0c559c8ad311 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -15,6 +15,7 @@ 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 +153,23 @@ def __init__( 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 +241,10 @@ def make_new_av( 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 +252,10 @@ def make_new_av( 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 1c1357a9b2b5a6..ccbbcf53a50fc2 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 @@ 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 @@ def set_away_mode(service: ServiceCall) -> None: 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 e3e966edde0d5f..9e02ecf8ec43d7 100644 --- a/homeassistant/components/streamlabswater/binary_sensor.py +++ b/homeassistant/components/streamlabswater/binary_sensor.py @@ -3,22 +3,20 @@ 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/coordinator.py b/homeassistant/components/streamlabswater/coordinator.py index df4a6056b36ce7..d038a3657b825d 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 dea3f081326626..5fc8ac73769823 100644 --- a/homeassistant/components/streamlabswater/sensor.py +++ b/homeassistant/components/streamlabswater/sensor.py @@ -10,15 +10,12 @@ 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 +56,11 @@ class StreamlabsWaterSensorEntityDescription(SensorEntityDescription): 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 abc828dac3f1be..1a41c7d22d7b3e 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -46,7 +46,12 @@ async_get_provider, async_setup_legacy, ) -from .models import SpeechMetadata, SpeechResult +from .models import ( + DEFAULT_AUDIO_PROCESSING, + SpeechAudioProcessing, + SpeechMetadata, + SpeechResult, +) __all__ = [ "DOMAIN", @@ -197,6 +202,11 @@ def supported_sample_rates(self) -> list[AudioSampleRates]: 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/legacy.py b/homeassistant/components/stt/legacy.py index 13144eae5b424d..27ae377003f68d 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -26,7 +26,12 @@ AudioFormats, AudioSampleRates, ) -from .models import SpeechMetadata, SpeechResult +from .models import ( + DEFAULT_AUDIO_PROCESSING, + SpeechAudioProcessing, + SpeechMetadata, + SpeechResult, +) _LOGGER = logging.getLogger(__name__) @@ -143,6 +148,11 @@ def supported_sample_rates(self) -> list[AudioSampleRates]: 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 40b43109778dd9..bfc3041b4cded8 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 247618a8dcd869..8ecf33e8f48309 100644 --- a/homeassistant/components/subaru/__init__.py +++ b/homeassistant/components/subaru/__init__.py @@ -4,7 +4,6 @@ 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 .const import ( DOMAIN, - ENTRY_CONTROLLER, - ENTRY_COORDINATOR, - ENTRY_VEHICLES, FETCH_INTERVAL, MANUFACTURER, PLATFORMS, @@ -37,12 +33,16 @@ 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 00000000000000..b0587bcb5a2246 --- /dev/null +++ b/homeassistant/components/subaru/button.py @@ -0,0 +1,99 @@ +"""Support for Subaru remote service buttons.""" + +from __future__ import annotations + +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 0ef4ed29941f1a..035931677c0a66 100644 --- a/homeassistant/components/subaru/config_flow.py +++ b/homeassistant/components/subaru/config_flow.py @@ -15,12 +15,7 @@ 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 +27,7 @@ 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 +99,7 @@ async def async_step_user( @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 d8692e6a8bc509..0ff9d6bec2cb3d 100644 --- a/homeassistant/components/subaru/const.py +++ b/homeassistant/components/subaru/const.py @@ -9,11 +9,6 @@ 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 @@ 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 73aec22250af10..c23c6eef506851 100644 --- a/homeassistant/components/subaru/coordinator.py +++ b/homeassistant/components/subaru/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging import time @@ -23,16 +24,27 @@ _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 3c5d6487cb5225..fa3dc95f354952 100644 --- a/homeassistant/components/subaru/device_tracker.py +++ b/homeassistant/components/subaru/device_tracker.py @@ -7,32 +7,23 @@ 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 eec5b01ab56c9a..60f54b993e57f7 100644 --- a/homeassistant/components/subaru/diagnostics.py +++ b/homeassistant/components/subaru/diagnostics.py @@ -13,23 +13,23 @@ ) 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 +42,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 be9628303b7f5f..ffae30aecd30e3 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 07caa0d63678c1..8af699fb45ffb9 100644 --- a/homeassistant/components/subaru/lock.py +++ b/homeassistant/components/subaru/lock.py @@ -6,17 +6,14 @@ 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 @@ VEHICLE_NAME, VEHICLE_VIN, ) +from .coordinator import SubaruConfigEntry from .remote_service import async_call_remote_service _LOGGER = logging.getLogger(__name__) @@ -31,13 +29,12 @@ 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 930f497d3fe5b6..a7f384d602f4af 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 acd71e186da409..1a20ad04d7235b 100644 --- a/homeassistant/components/subaru/remote_service.py +++ b/homeassistant/components/subaru/remote_service.py @@ -6,7 +6,7 @@ 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 880e0043fa8a92..1d9d50dc020760 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -26,15 +26,12 @@ 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 +135,12 @@ 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 699dca1f05d9f3..5e72848e46b3d6 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/sun/condition.py b/homeassistant/components/sun/condition.py index 40a6eb652c4302..90686ab9add20d 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -13,7 +13,6 @@ 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 +150,20 @@ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: super().__init__(hass, config) assert config.options is not None self._options = config.options - - 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 + 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") + + 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/sunricher_dali/__init__.py b/homeassistant/components/sunricher_dali/__init__.py index dfb49e414b6aa1..56b85bafb98855 100644 --- a/homeassistant/components/sunricher_dali/__init__.py +++ b/homeassistant/components/sunricher_dali/__init__.py @@ -84,7 +84,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 +96,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/diagnostics.py b/homeassistant/components/sunricher_dali/diagnostics.py new file mode 100644 index 00000000000000..eedf48fc4ec632 --- /dev/null +++ b/homeassistant/components/sunricher_dali/diagnostics.py @@ -0,0 +1,98 @@ +"""Diagnostics support for Sunricher DALI.""" + +from __future__ import annotations + +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/manifest.json b/homeassistant/components/sunricher_dali/manifest.json index d5a76d0d0d8bad..4332a0e2644730 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 27b40e9335d2f2..1c4af840fc33d5 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/strings.json b/homeassistant/components/sunricher_dali/strings.json index 5a2eccf42b2268..64fbe7ad1aac07 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/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 130242b7742fba..dc9310f1d8e5cb 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -9,7 +9,6 @@ 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 +23,7 @@ SERVICE_SET_LOCK_STATE, SERVICE_SET_PET_LOCATION, ) -from .coordinator import SurePetcareDataCoordinator +from .coordinator import SurePetcareConfigEntry, SurePetcareDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -32,15 +31,10 @@ 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 +43,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 +86,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 9600f87437e29b..6a4707fe7edfd2 100644 --- a/homeassistant/components/surepetcare/binary_sensor.py +++ b/homeassistant/components/surepetcare/binary_sensor.py @@ -12,26 +12,24 @@ 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/coordinator.py b/homeassistant/components/surepetcare/coordinator.py index d8112cebc90129..32a54484685bef 100644 --- a/homeassistant/components/surepetcare/coordinator.py +++ b/homeassistant/components/surepetcare/coordinator.py @@ -29,13 +29,15 @@ 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/lock.py b/homeassistant/components/surepetcare/lock.py index 09fadf8be60e04..dc9fa8dd96d84e 100644 --- a/homeassistant/components/surepetcare/lock.py +++ b/homeassistant/components/surepetcare/lock.py @@ -8,23 +8,21 @@ 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 a34675eee74279..0a0a4b505cee29 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -10,26 +10,25 @@ 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/switch/__init__.py b/homeassistant/components/switch/__init__.py index 3c173cf5b2a1da..b6ba6928abca62 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -21,7 +21,6 @@ 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 +50,6 @@ class SwitchDeviceClass(StrEnum): # 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 ea9adeb6f74e45..6edfd1d990d5d9 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/strings.json b/homeassistant/components/switch/strings.json index 40f629b9e64106..40576351e133ac 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_off": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::switch::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::switch::common::condition_for_name%]" } }, "name": "Switch is off" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::switch::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::switch::common::condition_for_name%]" } }, "name": "Switch is on" @@ -65,21 +73,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "toggle": { "description": "Toggles a switch on/off.", @@ -101,6 +94,9 @@ "fields": { "behavior": { "name": "[%key:component::switch::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::switch::common::trigger_for_name%]" } }, "name": "Switch turned off" @@ -110,6 +106,9 @@ "fields": { "behavior": { "name": "[%key:component::switch::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::switch::common::trigger_for_name%]" } }, "name": "Switch turned on" diff --git a/homeassistant/components/switch/triggers.yaml b/homeassistant/components/switch/triggers.yaml index 98cc334d8f56af..d501286b000b36 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/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index 6e4bf004a3ddc2..0459d872605e5a 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -9,7 +9,6 @@ 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 +16,7 @@ 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 +52,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 +65,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 await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - return unload_ok - -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 1ac81ec4e0dbb4..1e831306e87670 100644 --- a/homeassistant/components/switchbee/button.py +++ b/homeassistant/components/switchbee/button.py @@ -4,23 +4,21 @@ 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 7837798b0cbee1..e9e794f9910ec2 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -23,14 +23,12 @@ 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 +73,11 @@ 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/coordinator.py b/homeassistant/components/switchbee/coordinator.py index b0ea1707be8bd9..6f4577f43473bf 100644 --- a/homeassistant/components/switchbee/coordinator.py +++ b/homeassistant/components/switchbee/coordinator.py @@ -19,16 +19,18 @@ _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 247063ab18a7de..0b05dff0cbfcc0 100644 --- a/homeassistant/components/switchbee/cover.py +++ b/homeassistant/components/switchbee/cover.py @@ -14,23 +14,21 @@ 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 228667540df3b4..eff93b36b3980b 100644 --- a/homeassistant/components/switchbee/light.py +++ b/homeassistant/components/switchbee/light.py @@ -2,19 +2,17 @@ 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 +34,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 41538f6fd71d93..3332aad1ad401e 100644 --- a/homeassistant/components/switchbee/switch.py +++ b/homeassistant/components/switchbee/switch.py @@ -14,23 +14,21 @@ ) 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 a2768c202b77f6..590ddd2f123901 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -110,11 +110,34 @@ 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 +194,7 @@ 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/button.py b/homeassistant/components/switchbot/button.py index 3d9db9074f2026..f68a45390cbeab 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/config_flow.py b/homeassistant/components/switchbot/config_flow.py index d9b3ea44fe1410..4d73567d9f41e3 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -96,6 +96,7 @@ def __init__(self) -> None: 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 +198,13 @@ async def async_step_encrypted_auth( 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 +266,7 @@ async def async_step_encrypted_choose_method( """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 +281,12 @@ async def async_step_encrypted_key( """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 d871f18d964c69..142b13befcc082 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -206,3 +206,16 @@ class SupportedModels(StrEnum): 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 4c80c534812b60..e4f3f6a15ff876 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -72,6 +72,7 @@ def _needs_poll( # 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/fan.py b/homeassistant/components/switchbot/fan.py index 9a7260f592542f..66d407eed2e64e 100644 --- a/homeassistant/components/switchbot/fan.py +++ b/homeassistant/components/switchbot/fan.py @@ -131,6 +131,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 +149,11 @@ def preset_mode(self) -> str | None: """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 +166,16 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: 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/icons.json b/homeassistant/components/switchbot/icons.json index 29aedc20aa3f1a..7321ac67120cdf 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/manifest.json b/homeassistant/components/switchbot/manifest.json index b26fc77c6af7e7..d346058b1dfc05 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.1"] + "requirements": ["PySwitchbot==2.2.0"] } diff --git a/homeassistant/components/switchbot/quality_scale.yaml b/homeassistant/components/switchbot/quality_scale.yaml index 5226016c527f1e..21b79d086b3eb8 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/sensor.py b/homeassistant/components/switchbot/sensor.py index ab400b5806512d..e9c4ccb3d12605 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -2,9 +2,11 @@ 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 +16,7 @@ SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, @@ -29,14 +32,22 @@ 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 +56,7 @@ 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 +65,97 @@ 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 +166,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 +175,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 +206,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 +236,7 @@ def __init__( @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/strings.json b/homeassistant/components/switchbot/strings.json index 5d306ed2aaacfd..b6ec6f50831713 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" }, @@ -288,6 +291,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 +335,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 d67aaed3412c82..c336602f3ba0c6 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -2,22 +2,61 @@ 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 +75,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_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 1aec0ec73e1340..8ab53db23bdadc 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -75,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], @@ -97,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], @@ -115,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, @@ -316,7 +319,6 @@ async def make_device_data( ) devices_data.binary_sensors.append((device, coordinator)) devices_data.sensors.append((device, coordinator)) - if isinstance(device, Device) and device.device_type == "AI Art Frame": coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id @@ -324,9 +326,16 @@ async def make_device_data( devices_data.buttons.append((device, coordinator)) devices_data.sensors.append((device, coordinator)) devices_data.images.append((device, coordinator)) + if isinstance(device, Device) and device.device_type == "WeatherStation": + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.sensors.append((device, coordinator)) -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] @@ -349,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) @@ -361,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: diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index dac916c6caecb2..494d64a93209c8 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -11,13 +11,11 @@ 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 @@ class SwitchBotCloudBinarySensorEntityDescription(BinarySensorEntityDescription) 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 d64139a052c1e6..3e493ab9036610 100644 --- a/homeassistant/components/switchbot_cloud/button.py +++ b/homeassistant/components/switchbot_cloud/button.py @@ -12,12 +12,10 @@ 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 @@ class SwitchbotCloudButtonEntityDescription(ButtonEntityDescription): 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 629e34197f4a47..5276409b719de6 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -26,7 +26,6 @@ ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PRECISION_TENTHS, STATE_UNAVAILABLE, @@ -37,10 +36,9 @@ 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 @@ 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 15e958b4777431..809a289a3539fb 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 e5e7b745cbb5a0..0543d2bb5d0a5c 100644 --- a/homeassistant/components/switchbot_cloud/cover.py +++ b/homeassistant/components/switchbot_cloud/cover.py @@ -18,22 +18,21 @@ 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 45704d49922ad7..32675cf83f2960 100644 --- a/homeassistant/components/switchbot_cloud/fan.py +++ b/homeassistant/components/switchbot_cloud/fan.py @@ -13,13 +13,12 @@ ) 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 @@ 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 dc4824bd890102..808c4c02619073 100644 --- a/homeassistant/components/switchbot_cloud/humidifier.py +++ b/homeassistant/components/switchbot_cloud/humidifier.py @@ -12,13 +12,12 @@ 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 @@ 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 e6966845ae009b..9e513d8f4a25c7 100644 --- a/homeassistant/components/switchbot_cloud/image.py +++ b/homeassistant/components/switchbot_cloud/image.py @@ -6,22 +6,20 @@ 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 d3bf22beebbce6..eedba4377bee1a 100644 --- a/homeassistant/components/switchbot_cloud/light.py +++ b/homeassistant/components/switchbot_cloud/light.py @@ -14,12 +14,11 @@ ) 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 191b17c397e17d..916d3239ce2be9 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -5,22 +5,20 @@ 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 1b74756bbae2a3..2b3d7c3e8f248e 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -12,7 +12,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -26,8 +25,8 @@ 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_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 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): 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 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): "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,), @@ -257,16 +276,21 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): ), "Smart Radiator Thermostat": (BATTERY_DESCRIPTION,), "AI Art Frame": (BATTERY_DESCRIPTION,), + "WeatherStation": ( + BATTERY_DESCRIPTION, + TEMPERATURE_DESCRIPTION, + HUMIDITY_DESCRIPTION, + ), } 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]: @@ -310,7 +334,6 @@ def _set_attributes(self) -> None: 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 6883efff030620..2bd4ff41bcc248 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 2ca98f928b46de..d6e123f9183d2d 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -6,12 +6,11 @@ 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 @@ 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 595bcee8e2e15a..40e694225e0fdc 100644 --- a/homeassistant/components/switchbot_cloud/vacuum.py +++ b/homeassistant/components/switchbot_cloud/vacuum.py @@ -17,13 +17,11 @@ 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 @@ 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/syncthing/__init__.py b/homeassistant/components/syncthing/__init__.py index 091b9b0c949340..73850904a7b413 100644 --- a/homeassistant/components/syncthing/__init__.py +++ b/homeassistant/components/syncthing/__init__.py @@ -18,26 +18,19 @@ 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 cancel_listen_task(event: Event) -> None: 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 d57da2b30ca3ba..5304f1e8f3c688 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -6,7 +6,6 @@ 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.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 @@ 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/manifest.json b/homeassistant/components/syncthru/manifest.json index ec6ecce7acea75..6ff5b95edb2fd9 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/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index 9c99f3a4c2a4ce..d762304543717c 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -34,15 +34,13 @@ class SynologyDSMbuttonDescription(ButtonEntityDescription): BUTTONS: Final = [ SynologyDSMbuttonDescription( key="reboot", - name="Reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, press_action=lambda syno_api: syno_api.async_reboot, ), SynologyDSMbuttonDescription( key="shutdown", - name="Shutdown", - icon="mdi:power", + translation_key="shutdown", entity_category=EntityCategory.CONFIG, press_action=lambda syno_api: syno_api.async_shutdown, ), @@ -63,6 +61,7 @@ class SynologyDSMButton(ButtonEntity): """Defines a Synology DSM button.""" entity_description: SynologyDSMbuttonDescription + _attr_has_entity_name = True def __init__( self, @@ -75,7 +74,6 @@ def __init__( if TYPE_CHECKING: assert api.network is not None assert api.information is not None - self._attr_name = f"{api.network.hostname} {description.name}" self._attr_unique_id = f"{api.information.serial}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, api.information.serial)} diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index 3ffbcce54665a2..b40a53560484f8 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -5,7 +5,11 @@ 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 +60,9 @@ def __init__( ) 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/icons.json b/homeassistant/components/synology_dsm/icons.json index 27c0198af5b6e6..44f69904365395 100644 --- a/homeassistant/components/synology_dsm/icons.json +++ b/homeassistant/components/synology_dsm/icons.json @@ -1,5 +1,10 @@ { "entity": { + "button": { + "shutdown": { + "default": "mdi:power" + } + }, "sensor": { "cpu_15min_load": { "default": "mdi:chip" diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index 4d57beac4e424d..cec61912b82a78 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/sensor.py b/homeassistant/components/synology_dsm/sensor.py index dd46fa33c3a221..c6729e86af77b0 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -2,9 +2,10 @@ 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 +51,8 @@ class SynologyDSMSensorEntityDescription( ): """Describes Synology DSM sensor entity.""" + value_fn: Callable[[SynoDSMInformation, str], Any] = getattr + UTILISATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = ( SynologyDSMSensorEntityDescription( @@ -327,8 +330,10 @@ class 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 +550,15 @@ def available(self) -> bool: 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/strings.json b/homeassistant/components/synology_dsm/strings.json index f80b892664881e..aedd23f57729e7 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%]" }, @@ -76,6 +76,11 @@ "name": "Security status" } }, + "button": { + "shutdown": { + "name": "Shut down" + } + }, "sensor": { "cpu_15min_load": { "name": "CPU load average (15 min)" @@ -152,9 +157,6 @@ "temperature": { "name": "[%key:component::sensor::entity_component::temperature::name%]" }, - "uptime": { - "name": "Last boot" - }, "volume_disk_temp_avg": { "name": "Average disk temp" }, @@ -243,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/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index c057ae0c214727..b1274b2c2e80d6 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -21,7 +21,7 @@ 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 +57,24 @@ 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 +110,7 @@ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, ) -> bool: """Set up System Bridge from a config entry.""" @@ -198,8 +215,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 +232,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 +265,7 @@ def valid_device(device: str) -> str: 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 +289,7 @@ async def handle_get_processes_by_name( ) -> 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 +307,7 @@ async def handle_get_processes_by_name( 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 +316,7 @@ async def handle_open_path(service_call: ServiceCall) -> ServiceResponse: 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 +326,7 @@ async def handle_power_command(service_call: ServiceCall) -> ServiceResponse: 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 +334,7 @@ async def handle_open_url(service_call: ServiceCall) -> ServiceResponse: 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 +342,7 @@ async def handle_send_keypress(service_call: ServiceCall) -> ServiceResponse: 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 +448,27 @@ async def handle_send_text(service_call: ServiceCall) -> ServiceResponse: 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 883c74f2589d98..9a09bb5eac1c12 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -10,13 +10,11 @@ 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 +62,11 @@ def camera_in_use(data: SystemBridgeData) -> bool | None: 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/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 6fca2e5902fbf5..9736a6a5b9e610 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -36,18 +36,20 @@ 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 c7b1fab679a709..1c3b707d4eec1b 100644 --- a/homeassistant/components/system_bridge/media_player.py +++ b/homeassistant/components/system_bridge/media_player.py @@ -15,13 +15,11 @@ 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 +62,11 @@ 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 930557568b83a7..5cfccaf3a88e66 100644 --- a/homeassistant/components/system_bridge/media_source.py +++ b/homeassistant/components/system_bridge/media_source.py @@ -15,12 +15,22 @@ 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 +56,7 @@ async def async_resolve_media( ) -> 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 +72,14 @@ async def async_browse_media( 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 +124,7 @@ def _build_bridges(self) -> BrowseMediaSource: def _build_base_url( - entry: ConfigEntry, + entry: SystemBridgeConfigEntry, ) -> str: """Build base url for System Bridge media.""" return ( @@ -133,7 +134,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 +165,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 2b13fef071e119..1f43d75a367d62 100644 --- a/homeassistant/components/system_bridge/notify.py +++ b/homeassistant/components/system_bridge/notify.py @@ -17,8 +17,7 @@ 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 +36,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 220d2c8823bca4..341641e50a0744 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -17,7 +17,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PORT, PERCENTAGE, @@ -33,8 +32,7 @@ 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 +362,11 @@ def partition_usage( 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 12060c28669725..cf2b4a0442630d 100644 --- a/homeassistant/components/system_bridge/update.py +++ b/homeassistant/components/system_bridge/update.py @@ -3,23 +3,21 @@ 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 37e9ee3d929c99..4cf3d6b88c832a 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -20,7 +20,6 @@ integration_platform, ) from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass _LOGGER = logging.getLogger(__name__) @@ -40,7 +39,6 @@ def async_register( """Register system health callbacks.""" -@bind_hass @callback def async_register_info( hass: HomeAssistant, diff --git a/homeassistant/components/system_log/strings.json b/homeassistant/components/system_log/strings.json index e8bccb214381e4..27f512c41acff2 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/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index b161661f310838..a581a5b7647341 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -81,6 +81,15 @@ async def _wait_for_login() -> None: 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 +106,10 @@ async def _wait_for_login() -> None: 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 +221,7 @@ async def async_step_init( 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 44d7bbfe3279d9..789e02c44e0b8b 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -2,9 +2,10 @@ 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 +34,7 @@ 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 +68,45 @@ def __init__( 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 +134,75 @@ async def _async_update_data(self) -> 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 +445,7 @@ async def set_child_lock(self, device_id: str, enabled: bool) -> None: ) 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 fa85b30c11c01e..42e3138cbc3637 100644 --- a/homeassistant/components/tado/diagnostics.py +++ b/homeassistant/components/tado/diagnostics.py @@ -14,4 +14,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/strings.json b/homeassistant/components/tado/strings.json index 4d917f91f526f3..c9a931d8412872 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/tailscale/__init__.py b/homeassistant/components/tailscale/__init__.py index 549bf07e181e9e..686b26dfe3f28c 100644 --- a/homeassistant/components/tailscale/__init__.py +++ b/homeassistant/components/tailscale/__init__.py @@ -2,30 +2,25 @@ 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 c17b6c0d984918..37c0f57abae266 100644 --- a/homeassistant/components/tailscale/binary_sensor.py +++ b/homeassistant/components/tailscale/binary_sensor.py @@ -12,14 +12,15 @@ 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 +98,11 @@ class TailscaleBinarySensorEntityDescription(BinarySensorEntityDescription): 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/coordinator.py b/homeassistant/components/tailscale/coordinator.py index d1a0b540f47fe0..24acd4706f0289 100644 --- a/homeassistant/components/tailscale/coordinator.py +++ b/homeassistant/components/tailscale/coordinator.py @@ -14,13 +14,15 @@ 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 f9e63491660ec0..3396f74de89da7 100644 --- a/homeassistant/components/tailscale/diagnostics.py +++ b/homeassistant/components/tailscale/diagnostics.py @@ -6,12 +6,11 @@ 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 +21,19 @@ "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 a14b873a00fff7..914637394a2ebe 100644 --- a/homeassistant/components/tailscale/entity.py +++ b/homeassistant/components/tailscale/entity.py @@ -6,15 +6,13 @@ 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 +20,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 8c005888387dd0..8a8a7f3e851c54 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 cf944aa73eff28..90f19711faba5f 100644 --- a/homeassistant/components/tailscale/sensor.py +++ b/homeassistant/components/tailscale/sensor.py @@ -13,14 +13,15 @@ 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 +55,11 @@ class TailscaleSensorEntityDescription(SensorEntityDescription): 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/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py index 4d927b0769e292..14368f1647168b 100644 --- a/homeassistant/components/tailwind/binary_sensor.py +++ b/homeassistant/components/tailwind/binary_sensor.py @@ -19,6 +19,8 @@ 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 380eb7ccd7eb3d..66d6cf9f908ab4 100644 --- a/homeassistant/components/tailwind/button.py +++ b/homeassistant/components/tailwind/button.py @@ -22,6 +22,8 @@ from .coordinator import TailwindConfigEntry from .entity import TailwindEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class TailwindButtonEntityDescription(ButtonEntityDescription): @@ -66,7 +68,6 @@ async def async_press(self) -> None: 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 daf0fbd32b7bb7..df680abe36e5c9 100644 --- a/homeassistant/components/tailwind/config_flow.py +++ b/homeassistant/components/tailwind/config_flow.py @@ -15,7 +15,12 @@ ) 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 +148,46 @@ async def async_step_zeroconf_confirm( 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 +264,17 @@ async def _async_step_create_entry( }, ) + 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/coordinator.py b/homeassistant/components/tailwind/coordinator.py index 770751ccc3b5fd..10daaec8ac9851 100644 --- a/homeassistant/components/tailwind/coordinator.py +++ b/homeassistant/components/tailwind/coordinator.py @@ -5,6 +5,7 @@ from gotailwind import ( Tailwind, TailwindAuthenticationError, + TailwindConnectionError, TailwindDeviceStatus, TailwindError, ) @@ -45,5 +46,13 @@ async def _async_update_data(self) -> 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 84f38c7d579868..307e6bcd169901 100644 --- a/homeassistant/components/tailwind/cover.py +++ b/homeassistant/components/tailwind/cover.py @@ -26,6 +26,8 @@ from .coordinator import TailwindConfigEntry from .entity import TailwindDoorEntity +PARALLEL_UPDATES = 1 + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tailwind/manifest.json b/homeassistant/components/tailwind/manifest.json index 136492d884f0d7..1f4f4dde0d85b2 100644 --- a/homeassistant/components/tailwind/manifest.json +++ b/homeassistant/components/tailwind/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/tailwind", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["gotailwind==0.3.0"], + "requirements": ["gotailwind==0.4.0"], "zeroconf": [ { "properties": { diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py index ca6b610c3519cf..5bc4852b388373 100644 --- a/homeassistant/components/tailwind/number.py +++ b/homeassistant/components/tailwind/number.py @@ -18,6 +18,8 @@ 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 90c5d0d5837afe..595ef4a20e0b9c 100644 --- a/homeassistant/components/tailwind/quality_scale.yaml +++ b/homeassistant/components/tailwind/quality_scale.yaml @@ -32,7 +32,7 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: todo + parallel-updates: done reauthentication-flow: done test-coverage: done # Gold @@ -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 8cb059a74d09ec..ca7aac47564171 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 8b9a5e1a90f268..6abbf24ed70788 100644 --- a/homeassistant/components/tami4/__init__.py +++ b/homeassistant/components/tami4/__init__.py @@ -4,18 +4,17 @@ 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 +28,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 a1b8db79674d7a..bdd3e64ea47782 100644 --- a/homeassistant/components/tami4/button.py +++ b/homeassistant/components/tami4/button.py @@ -8,12 +8,11 @@ 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 @@ class Tami4EdgeDrinkButtonEntityDescription(ButtonEntityDescription): 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/const.py b/homeassistant/components/tami4/const.py index be737b5c974008..9717181eb4a46d 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 f65c819b3d8a09..e872d61dd374a7 100644 --- a/homeassistant/components/tami4/coordinator.py +++ b/homeassistant/components/tami4/coordinator.py @@ -13,6 +13,8 @@ _LOGGER = logging.getLogger(__name__) +type Tami4ConfigEntry = ConfigEntry[Tami4EdgeCoordinator] + @dataclass class FlattenedWaterQuality: @@ -37,10 +39,10 @@ def __init__(self, water_quality: WaterQuality) -> None: 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 @@ def __init__( 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/sensor.py b/homeassistant/components/tami4/sensor.py index 2bfd3079c19a15..c87694e31875e0 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 @@ 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/technove/config_flow.py b/homeassistant/components/technove/config_flow.py index 7ad9829b631033..0949b859884db0 100644 --- a/homeassistant/components/technove/config_flow.py +++ b/homeassistant/components/technove/config_flow.py @@ -6,7 +6,11 @@ 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 @@ async def async_step_user( 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 @@ async def async_step_user( }, ) + 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/manifest.json b/homeassistant/components/technove/manifest.json index 746c2280aaafc9..ea77023d0cc004 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/strings.json b/homeassistant/components/technove/strings.json index 98bb4b9562b708..c2e27854ccc7a6 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/ted5000/manifest.json b/homeassistant/components/ted5000/manifest.json index 7be221b3b85905..919f2377c81992 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/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index e757830811f66a..7ed11a1c556b3c 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -62,6 +62,7 @@ ATTR_DIRECTORY_PATH, ATTR_DISABLE_NOTIF, ATTR_DISABLE_WEB_PREV, + ATTR_DRAFT_ID, ATTR_FILE, ATTR_FILE_ID, ATTR_FILE_NAME, @@ -129,6 +130,7 @@ 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 +178,19 @@ ), ) +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 +439,7 @@ 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 +631,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 +931,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 fd45e4c219d543..f3e44428dfa9af 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -48,6 +48,7 @@ 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 @@ def __init__( """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 @@ async def send_media_group( 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 @@ async def set_message_reaction( 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 @@ async def download_file( **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 c2d6ed368edc6f..596b2a65861cd6 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -369,7 +369,7 @@ async def async_step_webhooks( 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 @@ async def async_step_reconfigure( 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 @@ async def async_step_reauth_confirm( 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 230b42f3040a86..7079cd2dc84b4f 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -31,6 +31,7 @@ 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_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/icons.json b/homeassistant/components/telegram_bot/icons.json index 411656329896b1..b7c92258027b11 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 48bf0c3a270478..3feb66ed4e71fe 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 d3bb993376f792..487d0d43a7fe62 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 c332484911c907..11673c647fff8c 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/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index c31227d0a635f1..31255dee71ca04 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -37,8 +37,6 @@ async def async_setup_bot_platform( pushbot = PushBot(hass, bot, config, secret_token) - await pushbot.start_application() - webhook_registered = await pushbot.register_webhook() if not webhook_registered: raise RuntimeError("Failed to register webhook with Telegram") @@ -49,6 +47,8 @@ async def async_setup_bot_platform( get_base_url(bot), ) + await pushbot.start_application() + hass.http.register_view( PushBotView( hass, diff --git a/homeassistant/components/teleinfo/__init__.py b/homeassistant/components/teleinfo/__init__.py new file mode 100644 index 00000000000000..7883d86d818b14 --- /dev/null +++ b/homeassistant/components/teleinfo/__init__.py @@ -0,0 +1,24 @@ +"""The Teleinfo integration.""" + +from __future__ import annotations + +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 00000000000000..dd19ece346d332 --- /dev/null +++ b/homeassistant/components/teleinfo/config_flow.py @@ -0,0 +1,130 @@ +"""Config flow for the Teleinfo integration.""" + +from __future__ import annotations + +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 00000000000000..85adf3cd705c89 --- /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 00000000000000..be68db51710f7a --- /dev/null +++ b/homeassistant/components/teleinfo/coordinator.py @@ -0,0 +1,62 @@ +"""DataUpdateCoordinator for the Teleinfo integration.""" + +from __future__ import annotations + +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 00000000000000..4aca973dedd789 --- /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 00000000000000..32b72035a528e8 --- /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 00000000000000..1f3d656ce6386c --- /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 00000000000000..677267c8467a01 --- /dev/null +++ b/homeassistant/components/teleinfo/sensor.py @@ -0,0 +1,245 @@ +"""Sensor platform for the Teleinfo integration.""" + +from __future__ import annotations + +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 00000000000000..9177943680395d --- /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 4f88b47b531f93..37d9d385fa58e1 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 bfa3f25f7357a3..8414b6695693de 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 2554acc428c82d..d1f5e73a0a97e4 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 86fdb4d1d64de3..b9a29b4d068fac 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 782f240cc413b5..732c3a79a7d65b 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -127,6 +127,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 346417f89895dc..47702d34ec0e83 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/temper/__init__.py b/homeassistant/components/temper/__init__.py index 587da1c6309fc3..79cabf609285c4 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/temperature/condition.py b/homeassistant/components/temperature/condition.py index 3bae43cc03bc9c..d1c18aa3773a69 100644 --- a/homeassistant/components/temperature/condition.py +++ b/homeassistant/components/temperature/condition.py @@ -48,6 +48,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 a979b371e00434..aa611b494e02c0 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 c970474b78e022..d39b92e0f5e176 100644 --- a/homeassistant/components/temperature/strings.json +++ b/homeassistant/components/temperature/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -12,6 +14,9 @@ "behavior": { "name": "[%key:component::temperature::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::temperature::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::temperature::common::condition_threshold_name%]" } @@ -19,21 +24,6 @@ "name": "Temperature value" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Temperature", "triggers": { "changed": { @@ -51,6 +41,9 @@ "behavior": { "name": "[%key:component::temperature::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::temperature::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::temperature::common::trigger_threshold_name%]" } diff --git a/homeassistant/components/temperature/trigger.py b/homeassistant/components/temperature/trigger.py index 79995349e66e65..4d65329b2eb51c 100644 --- a/homeassistant/components/temperature/trigger.py +++ b/homeassistant/components/temperature/trigger.py @@ -48,6 +48,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 1db551aedf824e..7da401d425254b 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 c1a136a29ef0ac..da070a0eaf6630 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -206,7 +206,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/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 8bccb47687d2ae..90d03d4f5c8878 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -281,6 +281,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/config_flow.py b/homeassistant/components/template/config_flow.py index 0a38f802e4b4a6..184f35b267c89f 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -13,6 +13,7 @@ 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, @@ -286,6 +287,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 a2823233336a36..730f5615a49393 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 @@ def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: 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 @@ def unique_id(self) -> str | None: """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 @@ async def _handle_triggered( 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/entity.py b/homeassistant/components/template/entity.py index f7b5c3ff989c85..951e2e19195963 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -168,6 +168,17 @@ def add_script( 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/number.py b/homeassistant/components/template/number.py index 9dd62100917639..a9da3f00960bf4 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -11,12 +11,18 @@ 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 +56,7 @@ 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 +131,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/sensor.py b/homeassistant/components/template/sensor.py index febde76c6b059f..7dea48f9e920b1 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -193,7 +193,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 +265,7 @@ def _validate_state( 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 027470fafa63bb..d7778fe03ea335 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", @@ -836,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%]", @@ -1128,6 +1130,62 @@ "motion": "[%key:component::event::entity_component::motion::name%]" } }, + "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%]", @@ -1142,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%]", @@ -1155,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%]", @@ -1178,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/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 134c42bded15ca..03f5f03e000db0 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -30,6 +30,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 +47,10 @@ def __init__( 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 +210,9 @@ def _handle_rendered_results(self) -> bool: 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/vacuum.py b/homeassistant/components/template/vacuum.py index f06ae13141b324..97afc7c4d392c8 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Callable import logging from typing import TYPE_CHECKING, Any @@ -17,6 +18,7 @@ SERVICE_SET_FAN_SPEED, SERVICE_START, SERVICE_STOP, + Segment, StateVacuumEntity, VacuumActivity, VacuumEntityFeature, @@ -59,12 +61,14 @@ _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 +81,7 @@ } SCRIPT_FIELDS = ( + CONF_CLEAN_SEGMENTS, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE, @@ -86,12 +91,19 @@ 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 +111,23 @@ 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 +234,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 +301,7 @@ def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disabl # 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 +319,13 @@ def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disabl 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 +341,41 @@ def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disabl (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/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json index 4b4ff818ffc2c9..dfab47d2f69474 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_wall_connector/__init__.py b/homeassistant/components/tesla_wall_connector/__init__.py index f6809c4f416ce4..f55e22691f0e07 100644 --- a/homeassistant/components/tesla_wall_connector/__init__.py +++ b/homeassistant/components/tesla_wall_connector/__init__.py @@ -5,21 +5,25 @@ 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 +36,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 +52,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 a1781c8d8fb24f..7d8c681a38451f 100644 --- a/homeassistant/components/tesla_wall_connector/binary_sensor.py +++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py @@ -8,13 +8,12 @@ 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 @@ class WallConnectorBinarySensorDescription( 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/coordinator.py b/homeassistant/components/tesla_wall_connector/coordinator.py index bc43a0581dcfb0..8fd683ff59abc3 100644 --- a/homeassistant/components/tesla_wall_connector/coordinator.py +++ b/homeassistant/components/tesla_wall_connector/coordinator.py @@ -26,6 +26,8 @@ _LOGGER = logging.getLogger(__name__) +type WallConnectorConfigEntry = ConfigEntry[WallConnectorData] + @dataclass class WallConnectorData: @@ -49,12 +51,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/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py index 8a57bb7c2f48bd..7cd1059a8a2174 100644 --- a/homeassistant/components/tesla_wall_connector/sensor.py +++ b/homeassistant/components/tesla_wall_connector/sensor.py @@ -9,7 +9,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfElectricCurrent, @@ -22,8 +21,8 @@ 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 @@ class WallConnectorSensorDescription( 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 2c00094b40b0d5..eb99d2bb2bd135 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/climate.py b/homeassistant/components/teslemetry/climate.py index a82a712ec72a61..2bcf65c21c45bc 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -96,7 +96,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/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 11d6a95d796a92..819c99ba60d9df 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -10,6 +10,7 @@ GatewayTimeout, InvalidResponse, InvalidToken, + LoginRequired, RateLimited, ServiceUnavailable, SubscriptionRequired, @@ -85,7 +86,7 @@ async def _async_update_data(self) -> 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 +137,7 @@ async def _async_update_data(self) -> dict[str, Any]: """Update vehicle data using Teslemetry API.""" try: data = (await self.api.vehicle_data(endpoints=ENDPOINTS))["response"] - except (InvalidToken, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( @@ -186,7 +187,7 @@ async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" try: data: dict[str, Any] = (await self.api.live_status())["response"] - except (InvalidToken, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( @@ -233,7 +234,7 @@ async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" try: data = (await self.api.site_info())["response"] - except (InvalidToken, SubscriptionRequired) as e: + except (InvalidToken, SubscriptionRequired, LoginRequired) as e: raise ConfigEntryAuthFailed from e except RETRY_EXCEPTIONS as e: raise UpdateFailed( @@ -279,7 +280,7 @@ async def _async_update_data(self) -> 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/manifest.json b/homeassistant/components/teslemetry/manifest.json index ca7b1c8335433a..c2397d30741d69 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/tessie/__init__.py b/homeassistant/components/tessie/__init__.py index 2077b05cdc5460..684a5eb9336599 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 @@ 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 - - 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"], + except TeslaFleetError as e: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from e + + 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 51a5c33b0d8774..0ecbf3aa2f3070 100644 --- a/homeassistant/components/tessie/binary_sensor.py +++ b/homeassistant/components/tessie/binary_sensor.py @@ -16,8 +16,9 @@ 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 +45,9 @@ class TessieBinarySensorEntityDescription(BinarySensorEntityDescription): 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 a370f5043231cb..b413823df31e73 100644 --- a/homeassistant/components/tessie/button.py +++ b/homeassistant/components/tessie/button.py @@ -2,17 +2,11 @@ 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 +23,22 @@ 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 +73,4 @@ def __init__( 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/config_flow.py b/homeassistant/components/tessie/config_flow.py index 14c6b93fdfd690..fc350856b0fcf0 100644 --- a/homeassistant/components/tessie/config_flow.py +++ b/homeassistant/components/tessie/config_flow.py @@ -3,15 +3,16 @@ 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 +24,24 @@ } +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 +54,10 @@ async def async_step_user( 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 +83,10 @@ async def async_step_reauth_confirm( 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/coordinator.py b/homeassistant/components/tessie/coordinator.py index 2a0c0e07f94aad..b0dbf32ba76de5 100644 --- a/homeassistant/components/tessie/coordinator.py +++ b/homeassistant/components/tessie/coordinator.py @@ -10,8 +10,7 @@ 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 +53,7 @@ def __init__( self, hass: HomeAssistant, config_entry: TessieConfigEntry, + api: Vehicle, api_key: str, vin: str, data: dict[str, Any], @@ -66,6 +66,7 @@ def __init__( 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 +75,14 @@ def __init__( 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 +224,11 @@ async def _async_update_data(self) -> 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/entity.py b/homeassistant/components/tessie/entity.py index 98a424eefc1845..20abf3f9750d03 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 @@ def __init__( 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 @@ def set(self, *args: Any) -> None: 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 41e619ac10d2c7..c37a9f4d0f6fea 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/manifest.json b/homeassistant/components/tessie/manifest.json index 312a5f03e74df9..53b259ea03aa00 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/models.py b/homeassistant/components/tessie/models.py index e4e4bb34e81a46..8bbdb3441070b0 100644 --- a/homeassistant/components/tessie/models.py +++ b/homeassistant/components/tessie/models.py @@ -4,7 +4,7 @@ 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 +40,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/sensor.py b/homeassistant/components/tessie/sensor.py index 449cd0d7073be2..f4cd3ef8e398d0 100644 --- a/homeassistant/components/tessie/sensor.py +++ b/homeassistant/components/tessie/sensor.py @@ -46,6 +46,7 @@ TessieEntity, TessieWallConnectorEntity, ) +from .helpers import charge_state_to_option from .models import TessieEnergyData, TessieVehicleData @@ -71,7 +72,7 @@ class TessieSensorEntityDescription(SensorEntityDescription): 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 +638,4 @@ def _async_update_attrs(self) -> None: """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 41134b38fda0aa..f33c978fd4aedf 100644 --- a/homeassistant/components/tessie/switch.py +++ b/homeassistant/components/tessie/switch.py @@ -30,8 +30,9 @@ 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 +72,10 @@ class TessieSwitchEntityDescription(SwitchEntityDescription): 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/text/condition.py b/homeassistant/components/text/condition.py index 7fe4ee44568256..3bfe2e2c947daf 100644 --- a/homeassistant/components/text/condition.py +++ b/homeassistant/components/text/condition.py @@ -45,6 +45,11 @@ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: 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 4fa290f7813ba4..73653c4dde17b6 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/strings.json b/homeassistant/components/text/strings.json index 0eae84e3013032..2d4b6f03a80d33 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -1,6 +1,7 @@ { "common": { - "condition_behavior_name": "Condition passes if" + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least" }, "conditions": { "is_equal_to": { @@ -9,6 +10,9 @@ "behavior": { "name": "[%key:component::text::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::text::common::condition_for_name%]" + }, "value": { "description": "The value to compare the text to.", "name": "Value" @@ -48,14 +52,6 @@ } } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - } - }, "services": { "set_value": { "description": "Sets the value of a text entity.", diff --git a/homeassistant/components/thermobeacon/sensor.py b/homeassistant/components/thermobeacon/sensor.py index 916ec91359a091..97c20dd3fb6dd6 100644 --- a/homeassistant/components/thermobeacon/sensor.py +++ b/homeassistant/components/thermobeacon/sensor.py @@ -116,6 +116,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/sensor.py b/homeassistant/components/thermopro/sensor.py index bc0774627847e9..76ff39be21928d 100644 --- a/homeassistant/components/thermopro/sensor.py +++ b/homeassistant/components/thermopro/sensor.py @@ -114,6 +114,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/thethingsnetwork/__init__.py b/homeassistant/components/thethingsnetwork/__init__.py index d3c6c8356cb713..4f9bf215408474 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 78ffceecf84d3a..9a8d0c824ef644 100644 --- a/homeassistant/components/thethingsnetwork/coordinator.py +++ b/homeassistant/components/thethingsnetwork/coordinator.py @@ -15,13 +15,15 @@ _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/manifest.json b/homeassistant/components/thethingsnetwork/manifest.json index 777c9c1a2110f5..7157af1c8ec02c 100644 --- a/homeassistant/components/thethingsnetwork/manifest.json +++ b/homeassistant/components/thethingsnetwork/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/thethingsnetwork", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["ttn_client==1.2.3"] + "requirements": ["ttn_client==1.3.0"] } diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index 5aa851d99ae805..334a6878e345ba 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -5,12 +5,12 @@ 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 @@ 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/thomson/__init__.py b/homeassistant/components/thomson/__init__.py index 3c1ce045f39d2d..5547707976611d 100644 --- a/homeassistant/components/thomson/__init__.py +++ b/homeassistant/components/thomson/__init__.py @@ -1 +1 @@ -"""The thomson component.""" +"""The Thomson integration.""" diff --git a/homeassistant/components/thread/__init__.py b/homeassistant/components/thread/__init__.py index 65a59e43f319e5..9ee9b21e9aac97 100644 --- a/homeassistant/components/thread/__init__.py +++ b/homeassistant/components/thread/__init__.py @@ -36,6 +36,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/diagnostics.py b/homeassistant/components/thread/diagnostics.py index c66aec3bac98e2..2d9deb9184a111 100644 --- a/homeassistant/components/thread/diagnostics.py +++ b/homeassistant/components/thread/diagnostics.py @@ -17,6 +17,7 @@ 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 +148,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/manifest.json b/homeassistant/components/thread/manifest.json index a00f7480ede3be..aa4d90c9e1a810 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/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index 2fa987782a9a0e..2b4335ea91469d 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -26,6 +26,7 @@ from .coordinator import ( TibberDataAPICoordinator, TibberDataCoordinator, + TibberFetchPriceCoordinator, TibberPriceCoordinator, ) from .services import async_setup_services @@ -44,6 +45,7 @@ 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 @@ -131,7 +133,11 @@ async def _close(event: Event) -> None: raise ConfigEntryNotReady("Fatal HTTP error from Tibber API") from err if tibber_connection.get_homes(only_active=True): - price_coordinator = TibberPriceCoordinator(hass, entry) + 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 diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py index 1110f81f621d8e..bfe3cdb145afd5 100644 --- a/homeassistant/components/tibber/coordinator.py +++ b/homeassistant/components/tibber/coordinator.py @@ -24,7 +24,7 @@ statistics_during_period, ) from homeassistant.const import UnitOfEnergy -from homeassistant.core import HomeAssistant +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 @@ -102,7 +102,7 @@ def __init__( config_entry: TibberConfigEntry, *, name: str, - update_interval: timedelta, + update_interval: timedelta | None = None, ) -> None: """Initialize the coordinator.""" super().__init__( @@ -278,21 +278,54 @@ async def _insert_statistics(self) -> None: class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]): - """Handle Tibber price data and insert statistics.""" + """Handle Tibber price data.""" def __init__( self, hass: HomeAssistant, config_entry: TibberConfigEntry, + price_fetch_coordinator: TibberFetchPriceCoordinator, ) -> None: """Initialize the price coordinator.""" super().__init__( hass, config_entry, name=f"{DOMAIN} price", - update_interval=timedelta(minutes=1), ) - self._tomorrow_price_poll_threshold_seconds = random.uniform(0, 3600 * 10) + self._price_fetch_coordinator = price_fetch_coordinator + self._unsub_price_fetch_listener: CALLBACK_TYPE | None = None + + @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.""" @@ -309,7 +342,30 @@ def _time_until_next_15_minute(self) -> timedelta: return next_run - now async def _async_update_data(self) -> dict[str, TibberHomeData]: - """Update data via API and return per-home data for sensors.""" + 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) @@ -341,28 +397,31 @@ def _needs_update(home: tibber.TibberHome) -> bool: return True if _has_prices_tomorrow(home): return False - if (today_end - now).total_seconds() < ( - self._tomorrow_price_poll_threshold_seconds + if now >= today_start + timedelta( + seconds=self._tomorrow_price_poll_threshold_seconds ): return True return False - homes_to_update = [home for home in active_homes if _needs_update(home)] + self.update_interval = timedelta(seconds=random.uniform(60, 60 * 10)) try: - if homes_to_update: - await asyncio.gather( - *(home.update_info_and_price_info() for home in homes_to_update) + await asyncio.gather( + *( + home.update_info_and_price_info() + for home in active_homes + if _needs_update(home) ) - 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} + ) + 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 - self.update_interval = self._time_until_next_15_minute() - return result + return {home.home_id: home for home in active_homes} class TibberDataAPICoordinator(TibberCoordinator[dict[str, TibberDevice]]): diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 774f4d0ee4a0ed..00b4120a727efc 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.37.2"] + "requirements": ["pyTibber==0.37.4"] } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 0ac0114a1c88ac..cd96d8000b0c5c 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -750,7 +750,7 @@ def __init__( ) -> 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, @@ -771,6 +771,11 @@ def __init__( 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() @@ -784,7 +789,8 @@ def _update_attributes(self) -> None: (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._price_data_available = False + self._attr_native_value = None return self._attr_native_unit_of_measurement = home_data.get( @@ -805,7 +811,7 @@ def _update_attributes(self) -> None: self._attr_extra_state_attributes["estimated_annual_consumption"] = home_data[ "estimated_annual_consumption" ] - self._attr_available = True + self._price_data_available = True class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]): diff --git a/homeassistant/components/tilt_ble/__init__.py b/homeassistant/components/tilt_ble/__init__.py index 9ac2cb91aff7ae..09bda4212f3195 100644 --- a/homeassistant/components/tilt_ble/__init__.py +++ b/homeassistant/components/tilt_ble/__init__.py @@ -14,27 +14,26 @@ 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 +41,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/sensor.py b/homeassistant/components/tilt_ble/sensor.py index 411484cf2fe8e3..d5f9bd342321fc 100644 --- a/homeassistant/components/tilt_ble/sensor.py +++ b/homeassistant/components/tilt_ble/sensor.py @@ -4,12 +4,10 @@ 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 +21,7 @@ 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 +83,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/timer/__init__.py b/homeassistant/components/timer/__init__.py index 85745aea8e427b..da4d0ee5975793 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -41,6 +41,7 @@ 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 +203,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 +251,7 @@ def extra_state_attributes(self) -> dict[str, Any]: 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 +277,7 @@ async def async_added_to_hass(self) -> None: # 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 +325,7 @@ def async_start(self, duration: timedelta | None = None) -> None: 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 +352,8 @@ def async_change(self, duration: timedelta) -> None: self._listener() self._end += duration self._remaining = new_remaining + # We don't use _fire_event_and_write_state here because we don't want to + # update last_transition self.async_write_ha_state() self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id}) self._listener = async_track_point_in_utc_time( @@ -366,8 +371,7 @@ def async_pause(self) -> None: self._remaining = self._end - dt_util.utcnow().replace(microsecond=0) self._state = STATUS_PAUSED self._end = None - self.async_write_ha_state() - self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {ATTR_ENTITY_ID: self.entity_id}) + self._fire_event_and_write_state(EVENT_TIMER_PAUSED) @callback def async_cancel(self) -> None: @@ -382,10 +386,7 @@ def async_cancel(self) -> None: self._end = None self._remaining = None self._running_duration = self._configured_duration - self.async_write_ha_state() - self.hass.bus.async_fire( - EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_id} - ) + self._fire_event_and_write_state(EVENT_TIMER_CANCELLED) @callback def async_finish(self) -> None: @@ -403,10 +404,8 @@ def async_finish(self) -> None: self._end = None self._remaining = None self._running_duration = self._configured_duration - self.async_write_ha_state() - self.hass.bus.async_fire( - EVENT_TIMER_FINISHED, - {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, + self._fire_event_and_write_state( + EVENT_TIMER_FINISHED, extra_attrs={ATTR_FINISHED_AT: end.isoformat()} ) @callback @@ -421,10 +420,8 @@ def _async_finished(self, time: datetime) -> None: self._end = None self._remaining = None self._running_duration = self._configured_duration - self.async_write_ha_state() - self.hass.bus.async_fire( - EVENT_TIMER_FINISHED, - {ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()}, + self._fire_event_and_write_state( + EVENT_TIMER_FINISHED, extra_attrs={ATTR_FINISHED_AT: end.isoformat()} ) async def async_update_config(self, config: ConfigType) -> None: @@ -435,3 +432,14 @@ async def async_update_config(self, config: ConfigType) -> None: 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 a94cf6009336f8..6930f7263e12f5 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 fcc398870aa714..769fdfd10f88b9 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/strings.json b/homeassistant/components/timer/strings.json index 4774247e912e42..ddc6e9db44f65e 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -1,6 +1,9 @@ { "common": { - "condition_behavior_name": "Condition passes if" + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_active": { @@ -8,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::timer::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::condition_for_name%]" } }, "name": "Timer is active" @@ -17,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::timer::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::condition_for_name%]" } }, "name": "Timer is idle" @@ -26,6 +35,9 @@ "fields": { "behavior": { "name": "[%key:component::timer::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::timer::common::condition_for_name%]" } }, "name": "Timer is paused" @@ -53,6 +65,16 @@ "finishes_at": { "name": "Finishes at" }, + "last_transition": { + "name": "Last transition", + "state": { + "cancelled": "Cancelled", + "finished": "Finished", + "paused": "Paused", + "restarted": "Restarted", + "started": "Started" + } + }, "remaining": { "name": "Remaining" }, @@ -62,18 +84,10 @@ } } }, - "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.", - "name": "Cancel" + "name": "Cancel timer" }, "change": { "description": "Changes a timer by adding or subtracting a given duration.", @@ -83,19 +97,19 @@ "name": "Duration" } }, - "name": "Change" + "name": "Change timer" }, "finish": { "description": "Finishes a running timer earlier than scheduled.", - "name": "Finish" + "name": "Finish timer" }, "pause": { "description": "Pauses a running timer, retaining the remaining duration for later continuation.", - "name": "[%key:common::action::pause%]" + "name": "Pause timer" }, "reload": { "description": "Reloads timers from the YAML-configuration.", - "name": "[%key:common::action::reload%]" + "name": "Reload timers" }, "start": { "description": "Starts a timer or restarts it with a provided duration.", @@ -105,8 +119,79 @@ "name": "Duration" } }, - "name": "[%key:common::action::start%]" + "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 00000000000000..3107fdeeff5fbd --- /dev/null +++ b/homeassistant/components/timer/trigger.py @@ -0,0 +1,183 @@ +"""Provides triggers for timers.""" + +from __future__ import annotations + +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 00000000000000..a94482202b898d --- /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/todo/condition.py b/homeassistant/components/todo/condition.py new file mode 100644 index 00000000000000..e3aebd4cd4a9f5 --- /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 00000000000000..f4ecf7b7354b26 --- /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/icons.json b/homeassistant/components/todo/icons.json index 3addb8400c7656..588b0e4b217438 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 883f7fac6f17cb..cc86b7a095f5d0 100644 --- a/homeassistant/components/todo/intent.py +++ b/homeassistant/components/todo/intent.py @@ -91,7 +91,7 @@ async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None # 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 +108,9 @@ async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None 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 +140,10 @@ async def _async_do_handle(self, target_list: TodoListEntity, item: str) -> None # 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 4bf0565f135c5e..eb6fe5b9b621ec 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 c576aebde9c4d9..8387850f6e588a 100644 --- a/homeassistant/components/todo/trigger.py +++ b/homeassistant/components/todo/trigger.py @@ -167,13 +167,29 @@ def _handle_entities_updated(self, tracked_entities: set[str]) -> None: """Handle entities being added/removed from the target.""" -class ItemAddedTrigger(ItemTriggerBase): - """todo item added trigger.""" +class ItemChangeTriggerBase(ItemTriggerBase): + """todo item change trigger base class.""" - def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + def __init__( + self, hass: HomeAssistant, config: TriggerConfig, description: str + ) -> None: """Initialize trigger.""" super().__init__(hass, config) self._entity_item_ids: dict[str, set[str] | None] = {} + self._description = description + + @abc.abstractmethod + def _is_matching_item(self, item: TodoItem) -> bool: + """Return true if the item matches the trigger condition.""" + + @abc.abstractmethod + def _get_items_diff( + self, old_item_ids: set[str], current_item_ids: set[str] + ) -> set[str]: + """Return the set of item ids that should be reported for this trigger. + + The calculation is based on the previous and current matching item ids. + """ @override @callback @@ -187,23 +203,29 @@ def _handle_item_change( return old_item_ids = self._entity_item_ids.get(entity_id) - current_item_ids = {item.uid for item in event.items if item.uid is not None} + current_item_ids = { + item.uid + for item in event.items + if item.uid is not None and self._is_matching_item(item) + } self._entity_item_ids[entity_id] = current_item_ids if old_item_ids is None: # Entity just became available, so no old items to compare against return - added_item_ids = current_item_ids - old_item_ids - if added_item_ids: + + different_item_ids = self._get_items_diff(old_item_ids, current_item_ids) + if different_item_ids: _LOGGER.debug( - "Detected added items with ids %s for entity %s", - added_item_ids, + "Detected %s items with ids %s for entity %s", + self._description, + different_item_ids, entity_id, ) payload = { ATTR_ENTITY_ID: entity_id, - "item_ids": sorted(added_item_ids), + "item_ids": sorted(different_item_ids), } - run_action(payload, description="todo item added trigger") + run_action(payload, description=f"todo item {self._description} trigger") @override @callback @@ -213,100 +235,64 @@ def _handle_entities_updated(self, tracked_entities: set[str]) -> None: del self._entity_item_ids[entity_id] -class ItemRemovedTrigger(ItemTriggerBase): - """todo item removed trigger.""" +class ItemAddedTrigger(ItemChangeTriggerBase): + """todo item added trigger.""" def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize trigger.""" - super().__init__(hass, config) - self._entity_item_ids: dict[str, set[str] | None] = {} + super().__init__(hass, config, description="added") @override - @callback - def _handle_item_change( - self, event: TodoItemChangeEvent, run_action: TriggerActionRunner - ) -> None: - """Listen for todo item changes.""" - entity_id = event.entity_id - if event.items is None: - self._entity_item_ids[entity_id] = None - return + def _is_matching_item(self, item: TodoItem) -> bool: + """Return true if the item matches the trigger condition.""" + return True - old_item_ids = self._entity_item_ids.get(entity_id) - current_item_ids = {item.uid for item in event.items if item.uid is not None} - self._entity_item_ids[entity_id] = current_item_ids - if old_item_ids is None: - # Entity just became available, so no old items to compare against - return - removed_item_ids = old_item_ids - current_item_ids - if removed_item_ids: - _LOGGER.debug( - "Detected removed items with ids %s for entity %s", - removed_item_ids, - entity_id, - ) - payload = { - ATTR_ENTITY_ID: entity_id, - "item_ids": sorted(removed_item_ids), - } - run_action(payload, description="todo item removed trigger") + @override + def _get_items_diff( + self, old_item_ids: set[str], current_item_ids: set[str] + ) -> set[str]: + """Return the set of item ids that match added items.""" + return current_item_ids - old_item_ids + + +class ItemRemovedTrigger(ItemChangeTriggerBase): + """todo item removed trigger.""" + + def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: + """Initialize trigger.""" + super().__init__(hass, config, description="removed") @override - @callback - def _handle_entities_updated(self, tracked_entities: set[str]) -> None: - """Clear stale state for entities that left the tracked set.""" - for entity_id in set(self._entity_item_ids) - tracked_entities: - del self._entity_item_ids[entity_id] + def _is_matching_item(self, item: TodoItem) -> bool: + """Return true if the item matches the trigger condition.""" + return True + + @override + def _get_items_diff( + self, old_item_ids: set[str], current_item_ids: set[str] + ) -> set[str]: + """Return the set of item ids that match removed items.""" + return old_item_ids - current_item_ids -class ItemCompletedTrigger(ItemTriggerBase): +class ItemCompletedTrigger(ItemChangeTriggerBase): """todo item completed trigger.""" def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None: """Initialize trigger.""" - super().__init__(hass, config) - self._entity_completed_item_ids: dict[str, set[str] | None] = {} + super().__init__(hass, config, description="completed") @override - @callback - def _handle_item_change( - self, event: TodoItemChangeEvent, run_action: TriggerActionRunner - ) -> None: - """Listen for todo item changes.""" - entity_id = event.entity_id - if event.items is None: - self._entity_completed_item_ids[entity_id] = None - return - - old_item_ids = self._entity_completed_item_ids.get(entity_id) - current_item_ids = { - item.uid - for item in event.items - if item.uid is not None and item.status == TodoItemStatus.COMPLETED - } - self._entity_completed_item_ids[entity_id] = current_item_ids - if old_item_ids is None: - # Entity just became available, so no old items to compare against - return - new_completed_item_ids = current_item_ids - old_item_ids - if new_completed_item_ids: - _LOGGER.debug( - "Detected new completed items with ids %s for entity %s", - new_completed_item_ids, - entity_id, - ) - payload = { - ATTR_ENTITY_ID: entity_id, - "item_ids": sorted(new_completed_item_ids), - } - run_action(payload, description="todo item completed trigger") + def _is_matching_item(self, item: TodoItem) -> bool: + """Return true if the item matches the trigger condition.""" + return item.status == TodoItemStatus.COMPLETED @override - @callback - def _handle_entities_updated(self, tracked_entities: set[str]) -> None: - """Clear stale state for entities that left the tracked set.""" - for entity_id in set(self._entity_completed_item_ids) - tracked_entities: - del self._entity_completed_item_ids[entity_id] + def _get_items_diff( + self, old_item_ids: set[str], current_item_ids: set[str] + ) -> set[str]: + """Return the set of item ids that match completed items.""" + return current_item_ids - old_item_ids TRIGGERS: dict[str, type[Trigger]] = { diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 2e30856d0dff9d..56793e52398bdd 100644 --- a/homeassistant/components/todoist/__init__.py +++ b/homeassistant/components/todoist/__init__.py @@ -5,12 +5,10 @@ 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 @@ 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 509ce593699b4c..37fbdea96c32dd 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -16,7 +16,6 @@ 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 +59,7 @@ 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 +115,11 @@ 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 41e7602836e300..6dcf886b49c824 100644 --- a/homeassistant/components/todoist/coordinator.py +++ b/homeassistant/components/todoist/coordinator.py @@ -15,6 +15,8 @@ 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 ec2c38c35ff412..2d55d0ecea235a 100644 --- a/homeassistant/components/todoist/todo.py +++ b/homeassistant/components/todoist/todo.py @@ -12,23 +12,21 @@ 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/tomato/__init__.py b/homeassistant/components/tomato/__init__.py index e8a67f7e3bc1ab..4441481d034310 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/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 7d6b9ed3f73de7..2c717cb4a98e5a 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -1,4 +1,5 @@ """The Tomorrow.io integration.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations @@ -29,7 +30,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 +50,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/coordinator.py b/homeassistant/components/tomorrowio/coordinator.py index 2a6b3675792d21..7894066317f8f7 100644 --- a/homeassistant/components/tomorrowio/coordinator.py +++ b/homeassistant/components/tomorrowio/coordinator.py @@ -24,6 +24,7 @@ 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 +117,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 +129,7 @@ def __init__( super().__init__( hass, LOGGER, - config_entry=config_entry, + config_entry=None, name=f"{DOMAIN}_{self._api.api_key_masked}", ) @@ -158,7 +157,15 @@ async def async_setup_entry(self, entry: ConfigEntry) -> None: "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 +191,7 @@ async def async_setup_entry(self, entry: ConfigEntry) -> None: 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/sensor.py b/homeassistant/components/tomorrowio/sensor.py index f288f011061c4a..03930f3ee1f019 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -331,6 +331,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 36b85515c3c215..52429dd7346ad9 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -69,6 +69,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 919a146ec93e5c..10d1e5a0bcb6e1 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -21,7 +21,7 @@ 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 eff8aed0a20d46..81c1c9c0a74fdb 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -9,12 +9,11 @@ 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 +25,11 @@ 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 5538a0abd91bcb..4a481a5f2555fb 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -21,24 +21,23 @@ 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/coordinator.py b/homeassistant/components/toon/coordinator.py index 894b4c913345bb..73ed06294d30b1 100644 --- a/homeassistant/components/toon/coordinator.py +++ b/homeassistant/components/toon/coordinator.py @@ -24,14 +24,16 @@ _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/sensor.py b/homeassistant/components/toon/sensor.py index e5b155b409b14c..c2ba8cb6984e24 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -10,7 +10,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfEnergy, @@ -22,7 +21,7 @@ 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 +36,11 @@ 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 d59a542d4d86a5..8ce8806df5362b 100644 --- a/homeassistant/components/toon/switch.py +++ b/homeassistant/components/toon/switch.py @@ -13,23 +13,21 @@ ) 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/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 5b3456cc2acbb8..47cbb0bc9bda68 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -1,4 +1,5 @@ """Component to embed TP-Link smart home devices.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/tplink_omada/diagnostics.py b/homeassistant/components/tplink_omada/diagnostics.py new file mode 100644 index 00000000000000..a0c22dbb6920eb --- /dev/null +++ b/homeassistant/components/tplink_omada/diagnostics.py @@ -0,0 +1,129 @@ +"""Diagnostics support for TP-Link Omada.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac + +from . import OmadaConfigEntry + +ENTRY_TO_REDACT = { + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, +} + +RUNTIME_TO_REDACT = { + "addr", + "echoServer", + "gateway", + "gateway2", + "hostName", + "ip", + "priDns", + "priDns2", + "sndDns", + "sndDns2", + "ssid", + "sn", + "omadacId", +} + + +def _build_identifier_replacements(mac_values: set[str]) -> dict[str, str]: + """Build deterministic replacement values for network identifiers.""" + replacements: dict[str, str] = {} + + for index, raw_mac in enumerate(sorted(mac_values)): + pseudonym = format_mac(str(index).zfill(12)) + variants = {raw_mac, raw_mac.upper(), raw_mac.lower()} + + normalized = format_mac(raw_mac) + variants.update({normalized, normalized.upper(), normalized.lower()}) + + for variant in variants: + replacements[variant] = pseudonym + + return replacements + + +def _replace_identifiers(data: Any, to_replace: Mapping[str, str]) -> Any: + """Replace network identifiers in nested diagnostics payloads.""" + if isinstance(data, Mapping): + return { + key: _replace_identifiers(value, to_replace) for key, value in data.items() + } + + if isinstance(data, list): + return [_replace_identifiers(item, to_replace) for item in data] + + if isinstance(data, str): + return to_replace.get(data, data) + + return data + + +def _redact_runtime_record( + raw_data: Mapping[str, Any], replacements: Mapping[str, str] +) -> dict[str, Any]: + """Apply identifier replacement and key redaction to runtime data.""" + return async_redact_data( + _replace_identifiers(raw_data, replacements), + RUNTIME_TO_REDACT, + ) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: OmadaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + controller = entry.runtime_data + + devices = controller.devices_coordinator.data + clients = controller.clients_coordinator.data + + gateway_data: dict[str, Any] | None = None + if ( + gateway_coordinator := controller.gateway_coordinator + ) and gateway_coordinator.data: + gateway = next(iter(gateway_coordinator.data.values())) + gateway_data = gateway.raw_data + + mac_values = set(devices) | set(clients) + for client in clients.values(): + if ap_mac := client.raw_data.get("apMac"): + mac_values.add(ap_mac) + if gateway_data and (gateway_mac := gateway_data.get("mac")): + mac_values.add(gateway_mac) + + replacements = _build_identifier_replacements(mac_values) + + return { + "entry": async_redact_data(entry.as_dict(), ENTRY_TO_REDACT), + "runtime": { + "devices": { + replacements[mac]: _redact_runtime_record( + device.raw_data, + replacements, + ) + for mac, device in devices.items() + }, + "clients": { + replacements[mac]: _redact_runtime_record( + client.raw_data, + replacements, + ) + for mac, client in clients.items() + }, + "gateway": ( + _redact_runtime_record(gateway_data, replacements) + if gateway_data is not None + else None + ), + }, + } diff --git a/homeassistant/components/tplink_omada/quality_scale.yaml b/homeassistant/components/tplink_omada/quality_scale.yaml index ace158c44ea87b..8259d41f47a657 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/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index f3138a113c4fa0..3538d017aa3af4 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -1,4 +1,5 @@ """Support for Traccar device tracking.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/traccar_server/sensor.py b/homeassistant/components/traccar_server/sensor.py index 09d85520dfb309..84132c9c17f761 100644 --- a/homeassistant/components/traccar_server/sensor.py +++ b/homeassistant/components/traccar_server/sensor.py @@ -14,7 +14,13 @@ 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 +51,26 @@ class TraccarServerSensorEntityDescription[_T](SensorEntityDescription): 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 c3571d251b5612..514636e105c4d9 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/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index c3e8938b2449a5..c7712d707c9c76 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -3,7 +3,6 @@ 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 +20,12 @@ ) 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 +40,14 @@ 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 +87,7 @@ async def on_hass_stop(event: Event) -> None: 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 +98,9 @@ async def on_hass_stop(event: Event) -> None: 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 +124,11 @@ async def async_keep_alive(now: datetime) -> None: 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/const.py b/homeassistant/components/tradfri/const.py index e42bb6f5f4d4b7..9a9da766baf751 100644 --- a/homeassistant/components/tradfri/const.py +++ b/homeassistant/components/tradfri/const.py @@ -7,8 +7,4 @@ 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 4c5c186626e5fc..2a760192eccb7f 100644 --- a/homeassistant/components/tradfri/coordinator.py +++ b/homeassistant/components/tradfri/coordinator.py @@ -3,9 +3,12 @@ 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 +21,30 @@ 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 b1fb9b153ad5c3..88d832035bb801 100644 --- a/homeassistant/components/tradfri/cover.py +++ b/homeassistant/components/tradfri/cover.py @@ -8,32 +8,30 @@ 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 4d89fd0081fe23..ac684886791cb1 100644 --- a/homeassistant/components/tradfri/diagnostics.py +++ b/homeassistant/components/tradfri/diagnostics.py @@ -4,19 +4,18 @@ 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 +27,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/fan.py b/homeassistant/components/tradfri/fan.py index e8fb7c050ede7c..d872f3017421b8 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -8,12 +8,11 @@ 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 +31,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 1aab244888ab41..7be436b17c549e 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -17,33 +17,31 @@ 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 b4a7c335481280..3a305fc53eef9e 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -15,7 +15,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, @@ -26,15 +25,8 @@ 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 +119,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 a2a1a5b4623684..81fa9c1db4e690 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -8,32 +8,30 @@ 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/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index d3d52c6979df80..1dc879da3b8255 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -25,7 +25,11 @@ 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 +38,14 @@ 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.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.EVENT, Platform.SENSOR, Platform.SWITCH] MIGRATION_NAME_TO_KEY = { # Sensors @@ -97,6 +102,17 @@ def update_unique_id( except (TransmissionConnectError, TransmissionError) as err: raise ConfigEntryNotReady from err + version = create_version(api.server_version) + if version.valid and version < MIN_REQUIRED_TRANSMISSION_VERSION: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="version_error", + translation_placeholders={ + "transmission_version": api.server_version, + "min_version": MIN_REQUIRED_TRANSMISSION_VERSION, + }, + ) + protocol: Final = "https" if config_entry.data[CONF_SSL] else "http" device_registry = dr.async_get(hass) device_registry.async_get_or_create( diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index 9294319aeb8806..29caef33269e62 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -40,8 +40,10 @@ DEFAULT_PORT, DEFAULT_SSL, DOMAIN, + MIN_REQUIRED_TRANSMISSION_VERSION, SUPPORTED_ORDER_MODES, ) +from .helpers import create_version DATA_SCHEMA = vol.Schema( { @@ -80,13 +82,17 @@ async def async_step_user( {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 +121,20 @@ async def async_step_reauth_confirm( 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 b603b9dc0e3762..ff29632c2e2d48 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -4,10 +4,13 @@ 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" @@ -55,6 +58,10 @@ EVENT_REMOVED_TORRENT = "transmission_removed_torrent" EVENT_DOWNLOADED_TORRENT = "transmission_downloaded_torrent" +EVENT_TYPE_STARTED = "started" +EVENT_TYPE_REMOVED = "removed" +EVENT_TYPE_DOWNLOADED = "downloaded" + STATE_UP_DOWN = "up_down" STATE_SEEDING = "seeding" STATE_DOWNLOADING = "downloading" diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index 6c848965f3b31b..c6af4eded27d10 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -1,19 +1,24 @@ -"""Coordinator for transmssion integration.""" +"""Coordinator for transmission integration.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from datetime import timedelta +from functools import partial import logging import transmission_rpc from transmission_rpc.session import SessionStats from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ID, ATTR_NAME, CONF_HOST +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( + ATTR_DOWNLOAD_PATH, + ATTR_LABELS, CONF_LIMIT, CONF_ORDER, DEFAULT_LIMIT, @@ -23,13 +28,28 @@ EVENT_DOWNLOADED_TORRENT, EVENT_REMOVED_TORRENT, EVENT_STARTED_TORRENT, + EVENT_TYPE_DOWNLOADED, + EVENT_TYPE_REMOVED, + EVENT_TYPE_STARTED, ) _LOGGER = logging.getLogger(__name__) +type EventCallback = Callable[[TransmissionEventData], None] type TransmissionConfigEntry = ConfigEntry[TransmissionDataUpdateCoordinator] +@dataclass +class TransmissionEventData: + """Data for a single event.""" + + event_type: str + name: str + id: int + download_path: str + labels: list[str] + + class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): """Transmission dataupdate coordinator class.""" @@ -48,6 +68,7 @@ def __init__( self._all_torrents: list[transmission_rpc.Torrent] = [] self._completed_torrents: list[transmission_rpc.Torrent] = [] self._started_torrents: list[transmission_rpc.Torrent] = [] + self._event_listeners: dict[str, EventCallback] = {} self.torrents: list[transmission_rpc.Torrent] = [] super().__init__( hass, @@ -67,9 +88,32 @@ def order(self) -> str: """Return order.""" return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER) # type: ignore[no-any-return] + @callback + def async_add_event_listener( + self, update_callback: EventCallback, target_event_id: str + ) -> Callable[[], None]: + """Listen for updates.""" + self._event_listeners[target_event_id] = update_callback + return partial(self.__async_remove_listener_internal, target_event_id) + + def __async_remove_listener_internal(self, listener_id: str) -> None: + self._event_listeners.pop(listener_id, None) + + @callback + def _async_notify_event_listeners(self, event: TransmissionEventData) -> None: + """Notify event listeners in the event loop.""" + for listener in list(self._event_listeners.values()): + listener(event) + async def _async_update_data(self) -> SessionStats: """Update transmission data.""" - return await self.hass.async_add_executor_job(self.update) + data = await self.hass.async_add_executor_job(self.update) + + self.check_completed_torrent() + self.check_started_torrent() + self.check_removed_torrent() + + return data def update(self) -> SessionStats: """Get the latest data from Transmission instance.""" @@ -80,10 +124,6 @@ def update(self) -> SessionStats: except transmission_rpc.TransmissionError as err: raise UpdateFailed("Unable to connect to Transmission client") from err - self.check_completed_torrent() - self.check_started_torrent() - self.check_removed_torrent() - return data def init_torrent_list(self) -> None: @@ -106,15 +146,24 @@ def check_completed_torrent(self) -> None: for torrent in current_completed_torrents: if torrent.id not in old_completed_torrents: - self.hass.bus.fire( + # Once event triggers are out of labs we can remove the bus event + self.hass.bus.async_fire( EVENT_DOWNLOADED_TORRENT, { - "name": torrent.name, - "id": torrent.id, - "download_path": torrent.download_dir, - "labels": torrent.labels, + ATTR_NAME: torrent.name, + ATTR_ID: torrent.id, + ATTR_DOWNLOAD_PATH: torrent.download_dir, + ATTR_LABELS: torrent.labels, }, ) + event = TransmissionEventData( + event_type=EVENT_TYPE_DOWNLOADED, + name=torrent.name, + id=torrent.id, + download_path=torrent.download_dir or "", + labels=torrent.labels, + ) + self._async_notify_event_listeners(event) self._completed_torrents = current_completed_torrents @@ -128,15 +177,24 @@ def check_started_torrent(self) -> None: for torrent in current_started_torrents: if torrent.id not in old_started_torrents: - self.hass.bus.fire( + # Once event triggers are out of labs we can remove the bus event + self.hass.bus.async_fire( EVENT_STARTED_TORRENT, { - "name": torrent.name, - "id": torrent.id, - "download_path": torrent.download_dir, - "labels": torrent.labels, + ATTR_NAME: torrent.name, + ATTR_ID: torrent.id, + ATTR_DOWNLOAD_PATH: torrent.download_dir, + ATTR_LABELS: torrent.labels, }, ) + event = TransmissionEventData( + event_type=EVENT_TYPE_STARTED, + name=torrent.name, + id=torrent.id, + download_path=torrent.download_dir or "", + labels=torrent.labels, + ) + self._async_notify_event_listeners(event) self._started_torrents = current_started_torrents @@ -146,15 +204,24 @@ def check_removed_torrent(self) -> None: for torrent in self._all_torrents: if torrent.id not in current_torrents: - self.hass.bus.fire( + # Once event triggers are out of labs we can remove the bus event + self.hass.bus.async_fire( EVENT_REMOVED_TORRENT, { - "name": torrent.name, - "id": torrent.id, - "download_path": torrent.download_dir, - "labels": torrent.labels, + ATTR_NAME: torrent.name, + ATTR_ID: torrent.id, + ATTR_DOWNLOAD_PATH: torrent.download_dir, + ATTR_LABELS: torrent.labels, }, ) + event = TransmissionEventData( + event_type=EVENT_TYPE_REMOVED, + name=torrent.name, + id=torrent.id, + download_path=torrent.download_dir or "", + labels=torrent.labels, + ) + self._async_notify_event_listeners(event) self._all_torrents = self.torrents.copy() diff --git a/homeassistant/components/transmission/event.py b/homeassistant/components/transmission/event.py new file mode 100644 index 00000000000000..79cf21a5ffda7f --- /dev/null +++ b/homeassistant/components/transmission/event.py @@ -0,0 +1,85 @@ +"""Define events for the Transmission integration.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from homeassistant.components.event import EventEntity, EventEntityDescription +from homeassistant.const import ATTR_ID, ATTR_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + ATTR_DOWNLOAD_PATH, + ATTR_LABELS, + EVENT_TYPE_DOWNLOADED, + EVENT_TYPE_REMOVED, + EVENT_TYPE_STARTED, +) +from .coordinator import TransmissionConfigEntry, TransmissionEventData +from .entity import TransmissionEntity + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TransmissionConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Transmission event platform.""" + coordinator = config_entry.runtime_data + + description = EventEntityDescription( + key="torrent", + translation_key="torrent", + event_types=[ + EVENT_TYPE_STARTED, + EVENT_TYPE_DOWNLOADED, + EVENT_TYPE_REMOVED, + ], + ) + + async_add_entities([TransmissionEvent(coordinator, description)]) + + +class TransmissionEvent(TransmissionEntity, EventEntity): + """Representation of a Transmission event entity.""" + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + if TYPE_CHECKING: + assert self._attr_unique_id + + self.async_on_remove( + self.coordinator.async_add_event_listener( + self._handle_event, self._attr_unique_id + ) + ) + + @callback + def _handle_event(self, event_data: TransmissionEventData) -> None: + """Handle the torrent events.""" + + event_type = event_data.event_type + + if event_type not in self.event_types: + _LOGGER.warning("Event type %s is not known", event_type) + return + + self._trigger_event( + event_type, + { + ATTR_NAME: event_data.name, + ATTR_ID: event_data.id, + ATTR_DOWNLOAD_PATH: event_data.download_path, + ATTR_LABELS: event_data.labels, + }, + ) + + self.async_write_ha_state() diff --git a/homeassistant/components/transmission/helpers.py b/homeassistant/components/transmission/helpers.py index 4a3ddc28b27a95..0fa111a6d525d4 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 0704898b0cf093..9cc3b1dfad3fd8 100644 --- a/homeassistant/components/transmission/icons.json +++ b/homeassistant/components/transmission/icons.json @@ -1,5 +1,10 @@ { "entity": { + "event": { + "torrent": { + "default": "mdi:folder-file-outline" + } + }, "sensor": { "active_torrents": { "default": "mdi:counter" diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 78646da6d28f48..34d229b9e6130c 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,6 +42,20 @@ } }, "entity": { + "event": { + "torrent": { + "name": "Torrent", + "state_attributes": { + "event_type": { + "state": { + "downloaded": "Downloaded", + "removed": "Removed", + "started": "Started" + } + } + } + } + }, "sensor": { "active_torrents": { "name": "Active torrents", @@ -108,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/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 0555f8a145a8fb..0518f732218857 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -3,20 +3,12 @@ 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 +19,31 @@ 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] - - -class HomeAssistantTuyaData(NamedTuple): - """Tuya data stored in the Home Assistant data object.""" - - manager: Manager - listener: SharingDeviceListener +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Tuya Services.""" + await async_setup_services(hass) -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) - - # 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 + listener = DeviceListener(hass, entry) + await hass.async_add_executor_job(listener.initialize) - # 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 +59,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 +88,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 +109,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 72b6121a0f234b..e91c0070d356a6 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -22,8 +22,8 @@ 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] = { diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 91ae00da39f338..aa625cc978f8c1 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -20,8 +20,8 @@ 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 +74,14 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): 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, diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 95ae72a94e5328..eeaf79ef40a154 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -14,8 +14,8 @@ 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, ...]] = { diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 3790f470b78255..08d8b68d08cdec 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -9,19 +9,23 @@ 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 +42,11 @@ def async_discover_device(device_ids: list[str]) -> None: 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 +69,11 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, + description: CameraEntityDescription, definition: TuyaCameraDefinition, ) -> 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 06db6ea9f4b864..231887c301b667 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -32,8 +32,8 @@ 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 = { diff --git a/homeassistant/components/tuya/coordinator.py b/homeassistant/components/tuya/coordinator.py new file mode 100644 index 00000000000000..31cc158a3ea9a9 --- /dev/null +++ b/homeassistant/components/tuya/coordinator.py @@ -0,0 +1,204 @@ +"""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, +) + +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, + 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, + ) + + 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 a2f8eec2a984bb..c3b0ad3de21fe1 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -35,8 +35,8 @@ 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 diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index ff4b64e67cde3d..d7314b28d493ab 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -4,17 +4,16 @@ from typing import Any -from tuya_device_handlers.device_wrapper import DEVICE_WARNINGS +from tuya_device_handlers.helpers.diagnostics import customer_device_as_dict from tuya_sharing import CustomerDevice from homeassistant.components.diagnostics import REDACTED 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 homeassistant.util import dt as dt_util -from . import TuyaConfigEntry from .const import DOMAIN, DPCode +from .coordinator import TuyaConfigEntry _REDACTED_DPCODES = { DPCode.ALARM_MESSAGE, @@ -79,52 +78,13 @@ def _async_device_as_dict( ) -> dict[str, Any]: """Represent a Tuya device as a dictionary.""" - # Base device information, without sensitive information. - data = { - "id": device.id, - "name": device.name, - "category": device.category, - "product_id": device.product_id, - "product_name": device.product_name, - "online": device.online, - "sub": device.sub, - "time_zone": device.time_zone, - "active_time": dt_util.utc_from_timestamp(device.active_time).isoformat(), - "create_time": dt_util.utc_from_timestamp(device.create_time).isoformat(), - "update_time": dt_util.utc_from_timestamp(device.update_time).isoformat(), - "function": {}, - "status_range": {}, - "status": {}, - "home_assistant": {}, - "set_up": device.set_up, - "support_local": device.support_local, - "local_strategy": device.local_strategy, - "warnings": DEVICE_WARNINGS.get(device.id), - } - - # Gather Tuya states - for dpcode, value in device.status.items(): - # These statuses may contain sensitive information, redact these.. - if dpcode in _REDACTED_DPCODES: - data["status"][dpcode] = REDACTED - continue - - data["status"][dpcode] = value + # Base device information + data = customer_device_as_dict(device) - # Gather Tuya functions - for function in device.function.values(): - data["function"][function.code] = { - "type": function.type, - "value": function.values, - } - - # Gather Tuya status ranges - for status_range in device.status_range.values(): - data["status_range"][status_range.code] = { - "type": status_range.type, - "value": status_range.values, - "report_type": status_range.report_type, - } + # Redact sensitive information. + for key in data["status"]: + if key in _REDACTED_DPCODES: + data["status"][key] = REDACTED # Gather information how this Tuya device is represented in Home Assistant device_registry = dr.async_get(hass) diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 33c729e91793eb..9230278472dc63 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -24,17 +24,15 @@ def __init__( 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_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: diff --git a/homeassistant/components/tuya/event.py b/homeassistant/components/tuya/event.py index 9809c8a928a10d..329980c5e3a3bc 100644 --- a/homeassistant/components/tuya/event.py +++ b/homeassistant/components/tuya/event.py @@ -25,8 +25,8 @@ 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 diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index baf3b74e71a7fb..d448bcfb9b08d4 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -15,23 +15,24 @@ 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 +58,10 @@ def async_discover_device(device_ids: list[str]) -> None: 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 +80,11 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, + description: FanEntityDescription, definition: TuyaFanDefinition, ) -> 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 1ab7418666de67..3c113a40ba1b4b 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -21,8 +21,8 @@ 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 diff --git a/homeassistant/components/tuya/icons.json b/homeassistant/components/tuya/icons.json index ef93acf327c7aa..8aa819ea980a94 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 2f786708b445ed..3c337fc5b7de5f 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -28,8 +28,8 @@ 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 diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 031c6fa571733b..abd3ef4558fb8a 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.15", + "tuya-device-handlers==0.0.18", "tuya-device-sharing-sdk==0.2.8" ] } diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 078865f5a24a72..39fe20ec68ef37 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -19,7 +19,6 @@ 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 +27,7 @@ DeviceCategory, DPCode, ) +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = { diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index 239aabd9bccc9a..5ecf07aad2af6e 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -11,8 +11,8 @@ 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 8192db57b1e10c..07dae88c55941e 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -14,8 +14,8 @@ 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 diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 7019fe098704c6..7f5c06f0678124 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -44,7 +44,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import TuyaConfigEntry from .const import ( DEVICE_CLASS_UNITS, DOMAIN, @@ -53,6 +52,7 @@ DeviceCategory, DPCode, ) +from .coordinator import TuyaConfigEntry from .entity import TuyaEntity CURRENT_WRAPPER = (ElectricityCurrentRawWrapper, ElectricityCurrentJsonWrapper) diff --git a/homeassistant/components/tuya/services.py b/homeassistant/components/tuya/services.py new file mode 100644 index 00000000000000..bef24571c2e5b3 --- /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 00000000000000..e3aaa5faf6ca74 --- /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 b10628230fff09..24da4b520060f3 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -20,8 +20,8 @@ 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, ...]] = { diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 7e3bf7ba118d11..b843f06dd0772b 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 c603c760b53663..e39838b209df06 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -20,8 +20,8 @@ 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 diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index f6e7b79bcddbc4..dd81148e1fffb4 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -16,6 +16,7 @@ from homeassistant.components.vacuum import ( StateVacuumEntity, + StateVacuumEntityDescription, VacuumActivity, VacuumEntityFeature, ) @@ -23,8 +24,8 @@ 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 +37,10 @@ TuyaVacuumActivity.ERROR: VacuumActivity.ERROR, } +VACUUMS: dict[DeviceCategory, StateVacuumEntityDescription] = { + DeviceCategory.SD: StateVacuumEntityDescription(key=""), +} + async def async_setup_entry( hass: HomeAssistant, @@ -51,9 +56,11 @@ def async_discover_device(device_ids: list[str]) -> None: 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 +80,11 @@ def __init__( self, device: CustomerDevice, device_manager: Manager, + description: StateVacuumEntityDescription, definition: TuyaVacuumDefinition, ) -> 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 00d62ad7824c80..ff471eb3977499 100644 --- a/homeassistant/components/tuya/valve.py +++ b/homeassistant/components/tuya/valve.py @@ -18,8 +18,8 @@ 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, ...]] = { diff --git a/homeassistant/components/twentemilieu/__init__.py b/homeassistant/components/twentemilieu/__init__.py index 1359e707601282..b6bbdeff9e79f4 100644 --- a/homeassistant/components/twentemilieu/__init__.py +++ b/homeassistant/components/twentemilieu/__init__.py @@ -2,17 +2,15 @@ from __future__ import annotations -import voluptuous as vol +from typing import Any -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 +18,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 19e3f4f3337672..ffe542885187be 100644 --- a/homeassistant/components/twentemilieu/calendar.py +++ b/homeassistant/components/twentemilieu/calendar.py @@ -27,7 +27,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/const.py b/homeassistant/components/twentemilieu/const.py index e5415e09b81f5d..f75dd53df16908 100644 --- a/homeassistant/components/twentemilieu/const.py +++ b/homeassistant/components/twentemilieu/const.py @@ -22,3 +22,11 @@ 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 d2cf5a887ef04f..a96c266c3a5028 100644 --- a/homeassistant/components/twentemilieu/coordinator.py +++ b/homeassistant/components/twentemilieu/coordinator.py @@ -4,12 +4,17 @@ 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 +51,15 @@ def __init__(self, hass: HomeAssistant, entry: TwenteMilieuConfigEntry) -> None: 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/manifest.json b/homeassistant/components/twentemilieu/manifest.json index b1cb98dbca6d84..9b25aae6e8253d 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 42ff152cb4d420..4cdcf2d6e485be 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 81751d10a81c51..5f8fe87cc73adb 100644 --- a/homeassistant/components/twentemilieu/sensor.py +++ b/homeassistant/components/twentemilieu/sensor.py @@ -12,11 +12,9 @@ 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 +34,25 @@ class TwenteMilieuSensorDescription(SensorEntityDescription): 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 +84,7 @@ def __init__( """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 06d4be585de682..db54581525e47d 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/ukraine_alarm/__init__.py b/homeassistant/components/ukraine_alarm/__init__.py index c5cdd3bfb3eaa7..15fa5683c12598 100644 --- a/homeassistant/components/ukraine_alarm/__init__.py +++ b/homeassistant/components/ukraine_alarm/__init__.py @@ -15,31 +15,32 @@ 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 9009031ea143c9..137de373e0c5c9 100644 --- a/homeassistant/components/ukraine_alarm/binary_sensor.py +++ b/homeassistant/components/ukraine_alarm/binary_sensor.py @@ -7,7 +7,6 @@ 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 +24,7 @@ DOMAIN, MANUFACTURER, ) -from .coordinator import UkraineAlarmDataUpdateCoordinator +from .coordinator import UkraineAlarmConfigEntry, UkraineAlarmDataUpdateCoordinator BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( @@ -63,12 +62,12 @@ 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/coordinator.py b/homeassistant/components/ukraine_alarm/coordinator.py index b4e1decb1a1a34..b9e3fbed4e8feb 100644 --- a/homeassistant/components/ukraine_alarm/coordinator.py +++ b/homeassistant/components/ukraine_alarm/coordinator.py @@ -21,16 +21,18 @@ 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 15b0fbafead2ec..042da2b61c1558 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -6,7 +6,7 @@ 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 470f0091fffd2d..b8823593614c1d 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -31,9 +31,11 @@ ) 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 +48,8 @@ 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 +101,21 @@ class UnifiButtonEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +128,6 @@ class UnifiButtonEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +154,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 c8c6a54f9fe035..1450f0638d7c54 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -1,8 +1,8 @@ """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. """ @@ -13,7 +13,6 @@ 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 +34,7 @@ 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 +61,6 @@ DEFAULT_VERIFY_SSL = False -MODEL_PORTS = { - "UniFi Dream Machine": 443, - "UniFi Dream Machine Pro": 443, -} - - class UnifiFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a UniFi Network config flow.""" @@ -144,7 +133,10 @@ async def async_step_user( 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( @@ -215,33 +207,34 @@ async def async_step_reauth( 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.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 new file mode 100644 index 00000000000000..9b840d77132677 --- /dev/null +++ b/homeassistant/components/unifi/coordinator.py @@ -0,0 +1,45 @@ +"""UniFi Network data update coordinator.""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +from aiounifi.interfaces.api_handlers import APIHandler + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import LOGGER + +if TYPE_CHECKING: + from .hub.hub import UnifiHub + +POLL_INTERVAL = timedelta(seconds=10) + + +class UnifiDataUpdateCoordinator[HandlerT: APIHandler](DataUpdateCoordinator[None]): + """Coordinator managing polling for a single UniFi API data source.""" + + def __init__( + self, + hub: UnifiHub, + handler: HandlerT, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hub.hass, + LOGGER, + name=f"UniFi {type(handler).__name__}", + config_entry=hub.config.entry, + update_interval=POLL_INTERVAL, + ) + self._handler = handler + + @property + def handler(self) -> HandlerT: + """Return the aiounifi handler managed by this coordinator.""" + return self._handler + + async def _async_update_data(self) -> None: + """Update data from the API handler.""" + await self._handler.update() diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 8d82c7334c6aad..2fa52bd108203e 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -35,6 +35,7 @@ from .hub import UnifiHub LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 CLIENT_TRACKER = "client" DEVICE_TRACKER = "device" diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 4b68287ce10f0f..2e87e8c5e764f6 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -3,7 +3,7 @@ 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 @@ -94,16 +94,14 @@ def async_client_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo: @dataclass(frozen=True, kw_only=True) -class UnifiEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( - EntityDescription -): +class UnifiEntityDescription[HandlerT: APIHandler, ItemT: ApiItem](EntityDescription): """UniFi Entity Description.""" api_handler_fn: Callable[[aiounifi.Controller], HandlerT] """Provide api_handler from api.""" device_info_fn: Callable[[UnifiHub, str], DeviceInfo | None] """Provide device info object based on hub and obj_id.""" - object_fn: Callable[[aiounifi.Controller, str], ApiItemT] + object_fn: Callable[[aiounifi.Controller, str], ItemT] """Retrieve object based on api and obj_id.""" unique_id_fn: Callable[[UnifiHub, str], str] """Provide a unique ID based on hub and obj_id.""" @@ -113,10 +111,12 @@ class UnifiEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( """Determine if config entry options allow creation of entity.""" available_fn: Callable[[UnifiHub, str], bool] = lambda hub, obj_id: hub.available """Determine if entity is available, default is if connection is working.""" - name_fn: Callable[[ApiItemT], str | None] = lambda obj: None + name_fn: Callable[[ItemT], str | None] = lambda obj: None """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 @@ -129,17 +129,17 @@ class UnifiEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( """If entity needs to do regular checks on state.""" -class UnifiEntity[HandlerT: APIHandler, ApiItemT: ApiItem](Entity): +class UnifiEntity[HandlerT: APIHandler, ItemT: ApiItem](Entity): """Representation of a UniFi entity.""" - entity_description: UnifiEntityDescription[HandlerT, ApiItemT] + entity_description: UnifiEntityDescription[HandlerT, ItemT] _attr_unique_id: str def __init__( self, obj_id: str, hub: UnifiHub, - description: UnifiEntityDescription[HandlerT, ApiItemT], + description: UnifiEntityDescription[HandlerT, ItemT], ) -> None: """Set up UniFi switch entity.""" self._obj_id = obj_id @@ -157,7 +157,12 @@ def __init__( 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: @@ -258,6 +263,11 @@ def async_initiate_state(self) -> None: """ self.async_update_state(ItemEvent.ADDED, self._obj_id) + @callback + def get_object(self) -> ItemT: + """Return the latest object for this entity.""" + return self.entity_description.object_fn(self.api, self._obj_id) + @callback @abstractmethod def async_update_state(self, event: ItemEvent, obj_id: str) -> None: diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py index 4fd3d34a51dc26..3400e707ba2526 100644 --- a/homeassistant/components/unifi/hub/entity_loader.py +++ b/homeassistant/components/unifi/hub/entity_loader.py @@ -12,30 +12,28 @@ from functools import partial from typing import TYPE_CHECKING, Any -from aiounifi.interfaces.api_handlers import ItemEvent +from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent from homeassistant.const import Platform from homeassistant.core import callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from ..const import LOGGER, UNIFI_WIRELESS_CLIENTS +from ..coordinator import UnifiDataUpdateCoordinator from ..entity import UnifiEntity, UnifiEntityDescription if TYPE_CHECKING: - from .. import UnifiConfigEntry from .hub import UnifiHub CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1) -POLL_INTERVAL = timedelta(seconds=10) class UnifiEntityLoader: """UniFi Network integration handling platforms for entity registration.""" - def __init__(self, hub: UnifiHub, config_entry: UnifiConfigEntry) -> None: + def __init__(self, hub: UnifiHub) -> None: """Initialize the UniFi entity loader.""" self.hub = hub self.api_updaters = ( @@ -48,28 +46,20 @@ def __init__(self, hub: UnifiHub, config_entry: UnifiConfigEntry) -> None: hub.api.sites.update, hub.api.system_information.update, hub.api.firewall_policies.update, - hub.api.traffic_rules.update, - hub.api.traffic_routes.update, hub.api.wlans.update, ) - self.polling_api_updaters = ( - hub.api.traffic_rules.update, - hub.api.traffic_routes.update, - ) self.wireless_clients = hub.hass.data[UNIFI_WIRELESS_CLIENTS] - self._data_update_coordinator = DataUpdateCoordinator( - hub.hass, - LOGGER, - name="Unifi entity poller", - config_entry=config_entry, - update_method=self._update_pollable_api_data, - update_interval=POLL_INTERVAL, - ) - - self._update_listener = self._data_update_coordinator.async_add_listener( - update_callback=lambda: None - ) + self._polling_coordinators: dict[int, UnifiDataUpdateCoordinator] = { + id(hub.api.traffic_rules): UnifiDataUpdateCoordinator( + hub, hub.api.traffic_rules + ), + id(hub.api.traffic_routes): UnifiDataUpdateCoordinator( + hub, hub.api.traffic_routes + ), + } + for coordinator in self._polling_coordinators.values(): + coordinator.async_add_listener(lambda: None) self.platforms: list[ tuple[ @@ -85,7 +75,15 @@ def __init__(self, hub: UnifiHub, config_entry: UnifiConfigEntry) -> None: async def initialize(self) -> None: """Initialize API data and extra client support.""" - await self._refresh_api_data() + await asyncio.gather( + self._refresh_api_data(), + self._refresh_data( + [ + coordinator.async_refresh + for coordinator in self._polling_coordinators.values() + ] + ), + ) self._restore_inactive_clients() self.wireless_clients.update_clients(set(self.hub.api.clients.values())) @@ -100,10 +98,6 @@ async def _refresh_data( if result is not None: LOGGER.warning("Exception on update %s", result) - async def _update_pollable_api_data(self) -> None: - """Refresh API data for pollable updaters.""" - await self._refresh_data(self.polling_api_updaters) - async def _refresh_api_data(self) -> None: """Refresh API data from network application.""" await self._refresh_data(self.api_updaters) @@ -165,6 +159,13 @@ def _should_add_entity( and description.supported_fn(self.hub, obj_id) ) + @callback + def get_data_update_coordinator( + self, handler: APIHandler + ) -> UnifiDataUpdateCoordinator | None: + """Return the polling coordinator for a handler, if available.""" + return self._polling_coordinators.get(id(handler)) + @callback def _load_entities( self, diff --git a/homeassistant/components/unifi/hub/hub.py b/homeassistant/components/unifi/hub/hub.py index 9ea887bdb29a71..6cf8825a26cd58 100644 --- a/homeassistant/components/unifi/hub/hub.py +++ b/homeassistant/components/unifi/hub/hub.py @@ -39,7 +39,7 @@ def __init__( self.hass = hass self.api = api self.config = UnifiConfig.from_config_entry(config_entry) - self.entity_loader = UnifiEntityLoader(self, config_entry) + self.entity_loader = UnifiEntityLoader(self) self._entity_helper = UnifiEntityHelper(hass, api) self.websocket = UnifiWebsocket(hass, api, self.signal_reachable) diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 842e9732b5e833..0b854d35825b71 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -28,6 +28,8 @@ ) from .hub import UnifiHub +PARALLEL_UPDATES = 0 + @callback def async_wlan_qr_code_image_fn(hub: UnifiHub, wlan: Wlan) -> bytes: @@ -54,7 +56,6 @@ class UnifiImageEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 32b66cf9da7fe8..c51b38d8adba3f 100644 --- a/homeassistant/components/unifi/light.py +++ b/homeassistant/components/unifi/light.py @@ -6,6 +6,7 @@ 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 +22,12 @@ ) 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 +38,8 @@ 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 +130,6 @@ class UnifiLightEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +175,27 @@ def async_initiate_state(self) -> None: 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 f9954e9743efb1..86d97ad7647d0b 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 00000000000000..637c1caad3bbab --- /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 4f3e8528256304..0204efa00b46f4 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -63,6 +63,8 @@ ) from .hub import UnifiHub +PARALLEL_UPDATES = 0 + @callback def async_bandwidth_sensor_allowed_fn(hub: UnifiHub, obj_id: str) -> bool: @@ -268,6 +270,7 @@ def make_wan_latency_entity_description( 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 +279,11 @@ def make_wan_latency_entity_description( 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 +355,7 @@ def make_device_temperature_entity_description( ) -> 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 +364,9 @@ def make_device_temperature_entity_description( 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 +411,6 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +427,6 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +444,13 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +459,9 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +478,8 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +496,8 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +512,21 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +555,6 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +562,7 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( ), 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 +570,16 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +588,6 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +595,7 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( ), 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 +604,6 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +611,12 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( ), 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 +630,6 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +642,6 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +656,6 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +670,6 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 +684,6 @@ class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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 6cd652871d8fd1..3dbffa8bbe9e76 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 ef6a7c1d42ce84..80c70ef736aa3e 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,14 +96,75 @@ "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": { "abort": { "integration_not_setup": "UniFi integration is not set up" @@ -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 b9fbf48cf49140..91c9abf5119572 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -50,6 +50,7 @@ ) 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 +68,8 @@ ) 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) @@ -208,8 +211,6 @@ async def async_traffic_rule_control_fn( """Control traffic rule state.""" traffic_rule = hub.api.traffic_rules[obj_id].raw await hub.api.request(TrafficRuleEnableRequest.create(traffic_rule, target)) - # Update the traffic rules so the UI is updated appropriately - await hub.api.traffic_rules.update() async def async_traffic_route_control_fn( @@ -218,8 +219,6 @@ async def async_traffic_route_control_fn( """Control traffic route state.""" traffic_route = hub.api.traffic_routes[obj_id].raw await hub.api.request(TrafficRouteSaveRequest.create(traffic_route, target)) - # Update the traffic routes so the UI is updated appropriately - await hub.api.traffic_routes.update() async def async_wlan_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None: @@ -262,8 +261,6 @@ class UnifiSwitchEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( ), 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, @@ -278,7 +275,6 @@ class UnifiSwitchEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( ), 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, @@ -305,7 +301,6 @@ class UnifiSwitchEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( ), 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, @@ -318,7 +313,6 @@ class UnifiSwitchEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( ), 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, @@ -331,7 +325,6 @@ class UnifiSwitchEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( ), 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, @@ -353,14 +346,13 @@ class UnifiSwitchEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( 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, @@ -446,11 +438,31 @@ def async_initiate_state(self) -> None: 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) + ): + await coordinator.async_request_refresh() 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) + ): + await coordinator.async_request_refresh() @callback def async_update_state( @@ -464,7 +476,7 @@ def async_update_state( return description = self.entity_description - obj = description.object_fn(self.api, self._obj_id) + obj = self.get_object() if (is_on := description.is_on_fn(self.hub, obj)) != self.is_on: self._attr_is_on = is_on diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index a53700ef969086..2ee157bcdef94d 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -19,9 +19,11 @@ 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 +32,7 @@ ) LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 1 async def async_device_control_fn(api: aiounifi.Controller, obj_id: str) -> None: @@ -95,7 +98,13 @@ async def async_install( 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 e92757ef11d996..18e932caa9966a 100644 --- a/homeassistant/components/unifi_access/__init__.py +++ b/homeassistant/components/unifi_access/__init__.py @@ -7,29 +7,47 @@ 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, Platform.BUTTON, Platform.EVENT, Platform.IMAGE, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] +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 a59dc4d2b1c881..f8bf2b59065ad0 100644 --- a/homeassistant/components/unifi_access/binary_sensor.py +++ b/homeassistant/components/unifi_access/binary_sensor.py @@ -8,7 +8,7 @@ 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 +24,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 d1c795006cf682..4527dfb048aafb 100644 --- a/homeassistant/components/unifi_access/button.py +++ b/homeassistant/components/unifi_access/button.py @@ -5,7 +5,7 @@ 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 +23,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 b6474936dfe9ca..3323468c6eca28 100644 --- a/homeassistant/components/unifi_access/config_flow.py +++ b/homeassistant/components/unifi_access/config_flow.py @@ -9,9 +9,11 @@ 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,6 +26,42 @@ 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: + 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: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return errors + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -31,26 +69,9 @@ async def async_step_user( errors: dict[str, str] = {} if user_input is not None: - session = async_get_clientsession( - self.hass, verify_ssl=user_input[CONF_VERIFY_SSL] - ) - client = UnifiAccessApiClient( - host=user_input[CONF_HOST], - api_token=user_input[CONF_API_TOKEN], - session=session, - verify_ssl=user_input[CONF_VERIFY_SSL], - ) - try: - await client.authenticate() - except ApiAuthError: - errors["base"] = "invalid_auth" - except ApiConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + errors = await self._validate_input(user_input) + if not errors: return self.async_create_entry( title="UniFi Access", data=user_input, @@ -68,6 +89,100 @@ async def async_step_user( 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: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST]}, + ) + errors = await self._validate_input(user_input) + if not errors: + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates=user_input, + ) + + suggested_values = user_input or dict(reconfigure_entry.data) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_API_TOKEN): str, + vol.Required(CONF_VERIFY_SSL): bool, + } + ), + suggested_values, + ), + 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: @@ -82,25 +197,13 @@ async def async_step_reauth_confirm( reauth_entry = self._get_reauth_entry() if user_input is not None: - session = async_get_clientsession( - self.hass, verify_ssl=reauth_entry.data[CONF_VERIFY_SSL] - ) - client = UnifiAccessApiClient( - host=reauth_entry.data[CONF_HOST], - api_token=user_input[CONF_API_TOKEN], - session=session, - verify_ssl=reauth_entry.data[CONF_VERIFY_SSL], + errors = await self._validate_input( + { + **reauth_entry.data, + CONF_API_TOKEN: user_input[CONF_API_TOKEN], + } ) - try: - await client.authenticate() - except ApiAuthError: - errors["base"] = "invalid_auth" - except ApiConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if not errors: return self.async_update_reload_and_abort( reauth_entry, data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}, diff --git a/homeassistant/components/unifi_access/const.py b/homeassistant/components/unifi_access/const.py index 36ac8fee8f9b68..129bd24ca21a8c 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 d9882c0ca8c01d..f2e7bf53546ca8 100644 --- a/homeassistant/components/unifi_access/coordinator.py +++ b/homeassistant/components/unifi_access/coordinator.py @@ -6,7 +6,9 @@ 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, @@ -15,18 +17,24 @@ ApiNotFoundError, Door, DoorLockRelayStatus, + DoorLockRule, DoorLockRuleStatus, + DoorLockRuleType, EmergencyStatus, UnifiAccessApiClient, WsMessageHandler, ) from unifi_access_api.models.websocket import ( + DeviceUpdate, HwDoorbell, InsightsAdd, LocationUpdateState, LocationUpdateV2, + LogAdd, + RemoteView, SettingUpdate, ThumbnailInfo, + V2DeviceUpdate, V2LocationState, V2LocationUpdate, WebsocketMessage, @@ -34,10 +42,16 @@ 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__) @@ -88,6 +102,7 @@ def __init__( ) self.client = client self._event_listeners: list[Callable[[DoorEvent], None]] = [] + self._device_to_door: dict[str, str] = {} @callback def async_subscribe_door_events( @@ -102,13 +117,71 @@ def _unsubscribe() -> None: self._event_listeners.append(event_callback) return _unsubscribe + 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 + 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 + new_status = DoorLockRuleStatus( + type=DoorLockRuleType.NONE + if lock_rule_type == DoorLockRuleType.RESET + else lock_rule_type + ) + updated_data = replace( + self.data, + door_lock_rules={ + **self.data.door_lock_rules, + door_id: new_status, + }, + ) + if self.last_update_success: + self.async_set_updated_data(updated_data) + else: + # Preserve coordinator error state while updating cached data + self.data = updated_data + self.async_update_listeners() + async def _async_setup(self) -> None: """Set up the WebSocket connection for push updates.""" 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( @@ -163,6 +236,16 @@ async def _async_update_data(self) -> 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, @@ -190,6 +273,23 @@ async def _async_get_door_lock_rule( 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") @@ -216,9 +316,20 @@ async def _handle_location_update(self, msg: WebsocketMessage) -> None: 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, @@ -241,12 +352,13 @@ def _process_door_update( 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 @@ -302,7 +414,7 @@ def _process_door_update( async def _handle_setting_update(self, msg: WebsocketMessage) -> None: """Handle settings update messages (evacuation/lockdown).""" if self.data is None: - return + return # type: ignore[unreachable] update = cast(SettingUpdate, msg) self.async_set_updated_data( replace( @@ -324,6 +436,51 @@ async def _handle_doorbell(self, msg: WebsocketMessage) -> None: {}, ) + 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) @@ -340,10 +497,40 @@ async def _handle_insights_add(self, msg: WebsocketMessage) -> None: 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 new file mode 100644 index 00000000000000..903838dd6c62d0 --- /dev/null +++ b/homeassistant/components/unifi_access/diagnostics.py @@ -0,0 +1,41 @@ +"""Diagnostics support for UniFi Access.""" + +from __future__ import annotations + +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 UnifiAccessConfigEntry + +TO_REDACT = {CONF_API_TOKEN} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: UnifiAccessConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = entry.runtime_data.data + return { + "entry_data": async_redact_data(dict(entry.data), TO_REDACT), + "coordinator_data": { + "doors": { + door_id: door.model_dump(mode="json") + for door_id, door in data.doors.items() + }, + "emergency": data.emergency.model_dump(mode="json"), + "door_lock_rules": { + door_id: rule.model_dump(mode="json") + for door_id, rule in data.door_lock_rules.items() + }, + "unconfirmed_lock_rule_doors": sorted(data.unconfirmed_lock_rule_doors), + "supports_lock_rules": data.supports_lock_rules, + "lock_rule_support_complete": data.lock_rule_support_complete, + "door_thumbnails": { + door_id: thumb.model_dump(mode="json") + for door_id, thumb in data.door_thumbnails.items() + }, + }, + } diff --git a/homeassistant/components/unifi_access/event.py b/homeassistant/components/unifi_access/event.py index b13bdce869e7d7..511a2330d798d4 100644 --- a/homeassistant/components/unifi_access/event.py +++ b/homeassistant/components/unifi_access/event.py @@ -7,6 +7,7 @@ from unifi_access_api import Door from homeassistant.components.event import ( + DoorbellEventType, EventDeviceClass, EventEntity, EventEntityDescription, @@ -31,7 +32,7 @@ class UnifiAccessEventEntityDescription(EventEntityDescription): key="doorbell", translation_key="doorbell", device_class=EventDeviceClass.DOORBELL, - event_types=["ring"], + event_types=[DoorbellEventType.RING], category="doorbell", ) @@ -55,11 +56,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 0480ee3603db61..f34c24f9199823 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 ccb45ede0c08d1..fd8d2f326ad0f3 100644 --- a/homeassistant/components/unifi_access/image.py +++ b/homeassistant/components/unifi_access/image.py @@ -3,17 +3,20 @@ 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 +27,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 +75,14 @@ def __init__( 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 f7ec9953fd68a0..07095919d5c19c 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 d86686eb8165ce..47ee52fa5b98d3 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 @@ -41,28 +37,34 @@ rules: # 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: todo - entity-category: todo - entity-device-class: todo - entity-disabled-by-default: todo - entity-translations: todo + 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: + 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: todo - repair-issues: todo - stale-devices: todo + reconfiguration-flow: done + 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 inject-websession: done - strict-typing: todo + strict-typing: done diff --git a/homeassistant/components/unifi_access/select.py b/homeassistant/components/unifi_access/select.py new file mode 100644 index 00000000000000..4193a5a1d4f662 --- /dev/null +++ b/homeassistant/components/unifi_access/select.py @@ -0,0 +1,102 @@ +"""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 +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator +from .entity import UnifiAccessEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: UnifiAccessConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up UniFi Access select entities.""" + coordinator = entry.runtime_data + added_doors: set[str] = set() + + @callback + def _async_add_lock_rule_selects() -> None: + new_door_ids = sorted(coordinator.get_lock_rule_sensor_door_ids() - added_doors) + if not new_door_ids: + return + + async_add_entities( + UnifiAccessDoorLockRuleSelectEntity( + coordinator, coordinator.data.doors[door_id] + ) + for door_id in new_door_ids + if door_id in coordinator.data.doors + ) + added_doors.update(new_door_ids) + + _async_add_lock_rule_selects() + entry.async_on_unload(coordinator.async_add_listener(_async_add_lock_rule_selects)) + + +class UnifiAccessDoorLockRuleSelectEntity(UnifiAccessEntity, SelectEntity): + """Select entity for choosing the active temporary lock rule on a door.""" + + _attr_translation_key = "door_lock_rule" + + def __init__( + self, + coordinator: UnifiAccessCoordinator, + door: Door, + ) -> None: + """Initialize the door lock rule select entity.""" + super().__init__(coordinator, door, "lock_rule_select") + + @property + def current_option(self) -> str | None: + """Return the currently active lock rule, or None if no rule is set.""" + rule_status = self.coordinator.get_lock_rule_status(self._door_id) + if rule_status is None or rule_status.type in ( + DoorLockRuleType.NONE, + DoorLockRuleType.RESET, + DoorLockRuleType.LOCK_NOW, + ): + return None + value = rule_status.type.value + return value if value in self.options else None + + @property + def options(self) -> list[str]: + """Return the available lock rule options.""" + opts = ["keep_lock", "keep_unlock", "custom", "reset"] + rule_status = self.coordinator.get_lock_rule_status(self._door_id) + if rule_status is not None and rule_status.type in ( + DoorLockRuleType.SCHEDULE, + DoorLockRuleType.LOCK_EARLY, + ): + opts.extend(["schedule", "lock_early"]) + return opts + + @property + def available(self) -> bool: + """Return whether the select should currently be shown as available.""" + return super().available and ( + self._door_id in self.coordinator.get_lock_rule_sensor_door_ids() + ) + + async def async_select_option(self, option: str) -> None: + """Apply the selected lock rule to the door.""" + if option == DoorLockRuleType.SCHEDULE.value: + return + try: + await self.coordinator.async_set_lock_rule(self._door_id, option) + except UnifiAccessError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="lock_rule_failed", + ) from err diff --git a/homeassistant/components/unifi_access/services.py b/homeassistant/components/unifi_access/services.py new file mode 100644 index 00000000000000..4efeeaf6a0fd7d --- /dev/null +++ b/homeassistant/components/unifi_access/services.py @@ -0,0 +1,114 @@ +"""Services for UniFi Access.""" + +from __future__ import annotations + +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 00000000000000..90458f2cf71568 --- /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 d287b25bec012a..d20fb8e81cd1cb 100644 --- a/homeassistant/components/unifi_access/strings.json +++ b/homeassistant/components/unifi_access/strings.json @@ -2,14 +2,27 @@ "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%]", "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%]" @@ -19,6 +32,19 @@ }, "description": "The API token for UniFi Access at {host} is invalid. Please provide a new token." }, + "reconfigure": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]", + "host": "[%key:common::config_flow::data::host%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "api_token": "[%key:component::unifi_access::config::step::user::data_description::api_token%]", + "host": "[%key:component::unifi_access::config::step::user::data_description::host%]", + "verify_ssl": "[%key:component::unifi_access::config::step::user::data_description::verify_ssl%]" + }, + "description": "Update the connection settings of this UniFi Access controller." + }, "user": { "data": { "api_token": "[%key:common::config_flow::data::api_token%]", @@ -67,6 +93,19 @@ "name": "Thumbnail" } }, + "select": { + "door_lock_rule": { + "name": "Lock rule", + "state": { + "custom": "Custom", + "keep_lock": "Keep locked", + "keep_unlock": "Keep unlocked", + "lock_early": "Lock early", + "reset": "Reset", + "schedule": "Schedule" + } + } + }, "sensor": { "door_lock_rule": { "name": "Lock rule", @@ -97,8 +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_discovery/__init__.py b/homeassistant/components/unifi_discovery/__init__.py new file mode 100644 index 00000000000000..d4c8b3cbf5f6a6 --- /dev/null +++ b/homeassistant/components/unifi_discovery/__init__.py @@ -0,0 +1,18 @@ +"""The UniFi Discovery integration.""" + +from __future__ import annotations + +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 00000000000000..2cf1251f6c6477 --- /dev/null +++ b/homeassistant/components/unifi_discovery/config_flow.py @@ -0,0 +1,46 @@ +"""Config flow for UniFi Discovery.""" + +from __future__ import annotations + +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 00000000000000..ebd5f2866d77a6 --- /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 00000000000000..5a4174a4f6b5b6 --- /dev/null +++ b/homeassistant/components/unifi_discovery/discovery.py @@ -0,0 +1,97 @@ +"""UniFi network device discovery.""" + +from __future__ import annotations + +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 00000000000000..84cbcfec26b2e7 --- /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 00000000000000..0f2759c86d7566 --- /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/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 9e359de481a084..aae41d2052fca7 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -40,7 +40,6 @@ 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 +63,7 @@ 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 +73,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 +140,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 +158,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 00000000000000..c1ecb6e23ea2cd --- /dev/null +++ b/homeassistant/components/unifiprotect/alarm_control_panel.py @@ -0,0 +1,118 @@ +"""Support for UniFi Protect NVR alarm control panel.""" + +from __future__ import annotations + +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/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 605c127d8c3f1e..5ac557ea856836 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -36,8 +36,6 @@ 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 +54,6 @@ 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 +202,6 @@ def __init__(self) -> None: 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 c8d438a53d5508..4fc9d85793e8aa 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -52,6 +52,10 @@ 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 @@ TYPE_EMPTY_VALUE = "" PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CAMERA, @@ -71,6 +76,7 @@ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SIREN, Platform.SWITCH, Platform.TEXT, ] @@ -82,7 +88,6 @@ 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 1cb56b7311f5f1..76672ce59be273 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -19,6 +19,8 @@ ModelType, ProtectAdoptableDeviceModel, PTZPatrol, + Relay, + Siren, WSSubscriptionMessage, ) from uiprotect.exceptions import ClientError, NotAuthorized @@ -83,9 +85,16 @@ def __init__( 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 +172,48 @@ def async_setup(self) -> None: 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 +385,13 @@ def _async_process_updates(self) -> None: 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 +420,40 @@ def _async_unsubscribe( 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 +464,16 @@ def _async_signal_device_update(self, device: ProtectDeviceType) -> None: 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/discovery.py b/homeassistant/components/unifiprotect/discovery.py deleted file mode 100644 index 3a7fb7c65e02ee..00000000000000 --- 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/event.py b/homeassistant/components/unifiprotect/event.py index 59363abbcb0991..33bb13bfb497f2 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -9,6 +9,7 @@ from uiprotect.data.nvr import Event, EventDetectedThumbnail from homeassistant.components.event import ( + DoorbellEventType, EventDeviceClass, EventEntity, EventEntityDescription, @@ -20,7 +21,6 @@ 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 +96,7 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: 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 +367,7 @@ def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None: 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 f66a963da4e39b..9c0b7a2732e2fe 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/manifest.json b/homeassistant/components/unifiprotect/manifest.json index b94b2250797b22..215381a8fe15a1 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/quality_scale.yaml b/homeassistant/components/unifiprotect/quality_scale.yaml index 01d7a68afc397c..edd61c9d060bfa 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/siren.py b/homeassistant/components/unifiprotect/siren.py new file mode 100644 index 00000000000000..1bf278f47d0656 --- /dev/null +++ b/homeassistant/components/unifiprotect/siren.py @@ -0,0 +1,223 @@ +"""UniFi Protect siren platform (Public API).""" + +from __future__ import annotations + +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 69ac175ae39aa1..44165067ed77f1 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" @@ -636,6 +641,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 +676,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 +700,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 a5b399ef8c41a7..137d4a9e41f719 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -5,22 +5,29 @@ 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 +428,12 @@ async def _set_highfps(obj: Camera, value: bool) -> None: ), ) +_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 +575,119 @@ def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: 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/utils.py b/homeassistant/components/unifiprotect/utils.py index b520e83a592874..4bcc0ae29124c4 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -37,13 +37,13 @@ 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 +126,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 +146,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/universal/media_player.py b/homeassistant/components/universal/media_player.py index 0f9df0c10f330a..38a06b1d85dff9 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -107,7 +107,8 @@ 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 ebfc8eaeece93b..464fb9cf57b0bc 100644 --- a/homeassistant/components/upb/__init__.py +++ b/homeassistant/components/upb/__init__.py @@ -8,19 +8,15 @@ 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 @@ def _element_changed(element, changeset): 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 ca88784c65e5bb..c314dafe549871 100644 --- a/homeassistant/components/upb/light.py +++ b/homeassistant/components/upb/light.py @@ -10,12 +10,12 @@ 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 @@ 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 45a1d664b15e36..a4c31207e26b24 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 @@ 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/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py index 16adcc51ddf5b8..19130004a77c37 100644 --- a/homeassistant/components/upcloud/config_flow.py +++ b/homeassistant/components/upcloud/config_flow.py @@ -107,6 +107,8 @@ async def async_step_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(CONF_SCAN_INTERVAL) 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 5a820e717f5276..fd74562ec51ee3 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 00000000000000..610cb4f65ff179 --- /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/icons.json b/homeassistant/components/update/icons.json index 3ed26f4b6bd79a..7c015da2478bd5 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/strings.json b/homeassistant/components/update/strings.json index 7634d59a3c3777..0b8484d0baf1b2 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -1,6 +1,35 @@ { "common": { - "trigger_behavior_name": "Trigger when" + "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" + }, + "conditions": { + "is_available": { + "description": "Tests if one or more updates are available.", + "fields": { + "behavior": { + "name": "[%key:component::update::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::update::common::condition_for_name%]" + } + }, + "name": "Update is available" + }, + "is_not_available": { + "description": "Tests if one or more updates are not available.", + "fields": { + "behavior": { + "name": "[%key:component::update::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::update::common::condition_for_name%]" + } + }, + "name": "Update is not available" + } }, "device_automation": { "extra_fields": { @@ -58,15 +87,6 @@ "name": "Firmware" } }, - "selector": { - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "clear_skipped": { "description": "Removes the skipped version marker from an update.", @@ -98,6 +118,9 @@ "fields": { "behavior": { "name": "[%key:component::update::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::update::common::trigger_for_name%]" } }, "name": "Update became available" diff --git a/homeassistant/components/update/triggers.yaml b/homeassistant/components/update/triggers.yaml index e4a276dd38ecc0..15ab518ef96518 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/uptime/sensor.py b/homeassistant/components/uptime/sensor.py index 488682a79c69d8..9aa6c4082ed943 100644 --- a/homeassistant/components/uptime/sensor.py +++ b/homeassistant/components/uptime/sensor.py @@ -24,7 +24,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/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json index c7c2ea469a87c1..08690dc8aab2b2 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 37cfcc1266de90..cb56136433adca 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -61,9 +61,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 dc519555859da1..a4e7f4a807ff81 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -4,11 +4,7 @@ from typing import Any -from pyuptimerobot import ( - UptimeRobotAuthenticationException, - UptimeRobotException, - UptimeRobotMonitor, -) +from pyuptimerobot import UptimeRobotMonitor from homeassistant.components.switch import ( SwitchDeviceClass, @@ -16,13 +12,12 @@ 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 +60,14 @@ def is_on(self) -> bool: """Return True if the entity is on.""" return bool(self._monitor.status == STATUS_UP) - async def _async_edit_monitor(self, **kwargs: Any) -> None: - """Edit monitor status.""" - try: - await self.api.async_edit_monitor(**kwargs) - except UptimeRobotAuthenticationException: - self.coordinator.config_entry.async_start_reauth(self.hass) - return - except UptimeRobotException as exception: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="api_exception", - translation_placeholders={"error": "Generic UptimeRobot exception"}, - ) from exception - - await self.coordinator.async_request_refresh() - + @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 579785273661c6..58126b4c458cdb 100644 --- a/homeassistant/components/uptimerobot/utils.py +++ b/homeassistant/components/uptimerobot/utils.py @@ -2,11 +2,44 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate -from pyuptimerobot import UptimeRobotMonitor +from pyuptimerobot import ( + UptimeRobotAuthenticationException, + UptimeRobotException, + 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/usb/__init__.py b/homeassistant/components/usb/__init__.py index ec726bba460667..25152a433822d9 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -4,6 +4,8 @@ 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 +28,45 @@ 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 +103,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 +186,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 +218,7 @@ def __init__( 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 +334,41 @@ def _async_remove_callback() -> None: 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 +420,7 @@ async def _async_process_removed_usb_device(self, device: USBDevice) -> None: 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 +491,16 @@ def _async_delayed_add_remove_scan(self) -> None: 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 +539,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 7035e2ab2cbf46..e2f6c3db62bc64 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.0"] } diff --git a/homeassistant/components/usb/models.py b/homeassistant/components/usb/models.py index 11eccd9cd9b533..75764f75cf9151 100644 --- a/homeassistant/components/usb/models.py +++ b/homeassistant/components/usb/models.py @@ -6,12 +6,23 @@ @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 00000000000000..24b6c33f524415 --- /dev/null +++ b/homeassistant/components/usb/serial_proxy_stub.py @@ -0,0 +1,43 @@ +"""ESPHome serial proxy URI handler stub for serialx.""" + +from __future__ import annotations + +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 23248e19f58681..f8be47db8d28ae 100644 --- a/homeassistant/components/usb/utils.py +++ b/homeassistant/components/usb/utils.py @@ -3,55 +3,57 @@ 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.""" - - # 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 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, + ) - 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) +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) - 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 +62,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/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index f7e6f6e3008235..c9c737c4999dc8 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -7,6 +7,7 @@ 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 +53,6 @@ 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 +113,11 @@ 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/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index ea9f3e3579e9d5..2cabf8952e199b 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/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 0347e401da8da1..73184020d317d3 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -22,16 +22,19 @@ 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 +74,6 @@ # 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 +113,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,44 +426,67 @@ def last_seen_segments(self) -> list[Segment] | None: 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}, - ) - - # 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, + translation_key="areas_not_mapped", + translation_placeholders={"areas": ", ".join(sorted(unhandled_areas))}, ) - 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.""" diff --git a/homeassistant/components/vacuum/conditions.yaml b/homeassistant/components/vacuum/conditions.yaml index 17932be49bfc04..a3ccd4a68a9a9f 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/strings.json b/homeassistant/components/vacuum/strings.json index 364a4bfef0ee79..95267e6a1e27e2 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_cleaning": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is cleaning" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is docked" @@ -27,6 +35,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is encountering an error" @@ -36,6 +47,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is paused" @@ -45,6 +59,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::condition_for_name%]" } }, "name": "Vacuum cleaner is returning" @@ -85,29 +102,16 @@ "exceptions": { "area_mapping_not_configured": { "message": "Area mapping is not configured for `{entity_id}`. Configure the segment-to-area mapping before using this action." + }, + "areas_not_mapped": { + "message": "The following areas are not mapped to any segments of targeted vacuums: {areas}" } }, "issues": { "segments_changed": { - "description": "", "title": "Vacuum segments have changed for {entity_id}" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "clean_area": { "description": "Tells a vacuum cleaner to clean one or more areas.", @@ -191,6 +195,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum returned to dock" @@ -200,6 +207,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum encountered an error" @@ -209,6 +219,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum cleaner paused cleaning" @@ -218,6 +231,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum cleaner started cleaning" @@ -227,6 +243,9 @@ "fields": { "behavior": { "name": "[%key:component::vacuum::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::vacuum::common::trigger_for_name%]" } }, "name": "Vacuum cleaner started returning to dock" diff --git a/homeassistant/components/vacuum/triggers.yaml b/homeassistant/components/vacuum/triggers.yaml index e0266db92bcbc7..95c3b4da91610e 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/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 785ecd09fb115e..f3e65fa89256e0 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -3,28 +3,18 @@ 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 +40,16 @@ 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) - ), - } -) - - -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(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Vallox integration.""" + async_setup_services(hass) + return True -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 +57,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 a205dd2039eceb..d7268a3806fa15 100644 --- a/homeassistant/components/vallox/binary_sensor.py +++ b/homeassistant/components/vallox/binary_sensor.py @@ -8,13 +8,11 @@ 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 +59,11 @@ class ValloxBinarySensorEntityDescription(BinarySensorEntityDescription): 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/coordinator.py b/homeassistant/components/vallox/coordinator.py index 2fe7fa533db34d..ffaae9b1e00eab 100644 --- a/homeassistant/components/vallox/coordinator.py +++ b/homeassistant/components/vallox/coordinator.py @@ -15,16 +15,18 @@ _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 da2906c02c2c27..96b38298d87789 100644 --- a/homeassistant/components/vallox/date.py +++ b/homeassistant/components/vallox/date.py @@ -4,16 +4,12 @@ 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 +23,11 @@ def __init__( 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 +38,18 @@ def native_value(self) -> date | None: 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/fan.py b/homeassistant/components/vallox/fan.py index 8519b4cb913da3..6d94e2ed1a330a 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -5,17 +5,16 @@ 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 +24,7 @@ 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 +57,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 +82,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 +178,7 @@ async def async_set_percentage(self, percentage: int) -> None: async def _async_set_power(self, mode: bool) -> bool: try: - await self._client.set_values( + await self.coordinator.client.set_values( {METRIC_KEY_MODE: MODE_ON if mode else MODE_OFF} ) except ValloxApiException as err: @@ -206,7 +196,7 @@ async def _async_set_preset_mode_internal(self, preset_mode: str) -> bool: try: profile = PRESET_MODE_TO_VALLOX_PROFILE[preset_mode] - await self._client.set_profile(profile) + await self.coordinator.client.set_profile(profile) except ValloxApiException as err: raise HomeAssistantError(f"Failed to set profile: {preset_mode}") from err @@ -227,7 +217,7 @@ async def _async_set_percentage_internal( ) 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 ce3b9c72a6d8cb..b879b9201714f4 100644 --- a/homeassistant/components/vallox/number.py +++ b/homeassistant/components/vallox/number.py @@ -4,20 +4,16 @@ 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 +28,6 @@ def __init__( name: str, coordinator: ValloxDataUpdateCoordinator, description: ValloxNumberEntityDescription, - client: Vallox, ) -> None: """Initialize the Vallox number entity.""" super().__init__(name, coordinator) @@ -40,7 +35,6 @@ def __init__( 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 +48,7 @@ def native_value(self) -> float | None: 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 +97,13 @@ class ValloxNumberEntityDescription(NumberEntityDescription): 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 e9194a8254c32b..54f7ecd28105a2 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -11,9 +11,9 @@ 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 +26,12 @@ 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 +278,12 @@ class ValloxSensorEntityDescription(SensorEntityDescription): 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 00000000000000..2d6c3f4463ca63 --- /dev/null +++ b/homeassistant/components/vallox/services.py @@ -0,0 +1,138 @@ +"""Services for the Vallox integration.""" + +from __future__ import annotations + +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 9386f914f58c8e..2a023f09a54b28 100644 --- a/homeassistant/components/vallox/switch.py +++ b/homeassistant/components/vallox/switch.py @@ -5,16 +5,12 @@ 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 +25,6 @@ def __init__( name: str, coordinator: ValloxDataUpdateCoordinator, description: ValloxSwitchEntityDescription, - client: Vallox, ) -> None: """Initialize the Vallox switch.""" super().__init__(name, coordinator) @@ -37,7 +32,6 @@ def __init__( 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 +53,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: 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 +75,13 @@ class ValloxSwitchEntityDescription(SwitchEntityDescription): 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/conditions.yaml b/homeassistant/components/valve/conditions.yaml index b639ae832e7b1a..eaf8a041cc2f10 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/strings.json b/homeassistant/components/valve/strings.json index 3775f38fb1851e..f433e87b02bb94 100644 --- a/homeassistant/components/valve/strings.json +++ b/homeassistant/components/valve/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_closed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::valve::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::valve::common::condition_for_name%]" } }, "name": "Valve is closed" @@ -18,6 +23,9 @@ "fields": { "behavior": { "name": "[%key:component::valve::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::valve::common::condition_for_name%]" } }, "name": "Valve is open" @@ -46,21 +54,6 @@ "name": "Water" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "close_valve": { "description": "Closes a valve.", @@ -96,6 +89,9 @@ "fields": { "behavior": { "name": "[%key:component::valve::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::valve::common::trigger_for_name%]" } }, "name": "Valve closed" @@ -105,6 +101,9 @@ "fields": { "behavior": { "name": "[%key:component::valve::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::valve::common::trigger_for_name%]" } }, "name": "Valve opened" diff --git a/homeassistant/components/valve/triggers.yaml b/homeassistant/components/valve/triggers.yaml index aaf09598d65aff..fa880594ba6b47 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/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index e43ad364e841c0..91a80164156704 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -6,12 +6,12 @@ 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 +84,28 @@ async def async_step_network( 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 +133,10 @@ async def async_step_usbselect( ) -> 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 +217,7 @@ async def async_step_vlp( 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 +242,7 @@ async def async_step_reconfigure( 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/manifest.json b/homeassistant/components/velbus/manifest.json index eb4c90aaf83eaa..b01c5bb48e1731 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.4.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 d4592159d591d0..0550837aed16fa 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/velux/cover.py b/homeassistant/components/velux/cover.py index 334dab34cea739..685768f96a0fe2 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -97,13 +97,17 @@ def __init__(self, node: OpeningDevice, config_entry_id: str) -> None: 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 +172,29 @@ def __init__( 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 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: + 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 +238,8 @@ def __init__(self, node: Blind, config_entry_id: str) -> None: @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/entity.py b/homeassistant/components/velux/entity.py index a43eba6cb7b3e0..3da1d1038d156f 100644 --- a/homeassistant/components/velux/entity.py +++ b/homeassistant/components/velux/entity.py @@ -56,7 +56,6 @@ def __init__(self, node: Node, config_entry_id: str) -> None: 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/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index faa47bfc8e4320..e85bb83ce237a3 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -4,7 +4,6 @@ from venstarcolortouch import VenstarColorTouch -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -15,13 +14,15 @@ ) 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 +47,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 18c7abdc8cc5f1..415310e9f14c7f 100644 --- a/homeassistant/components/venstar/binary_sensor.py +++ b/homeassistant/components/venstar/binary_sensor.py @@ -4,21 +4,20 @@ 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 67fa08fcc12a53..d4acfa7a638379 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -20,7 +20,7 @@ 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 +50,7 @@ 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 +70,11 @@ 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( @@ -122,7 +122,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 2c5a51425adb90..5438658c44b2f9 100644 --- a/homeassistant/components/venstar/coordinator.py +++ b/homeassistant/components/venstar/coordinator.py @@ -14,16 +14,18 @@ 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 b8a4b971a7f53b..5089ee5f8e73ab 100644 --- a/homeassistant/components/venstar/entity.py +++ b/homeassistant/components/venstar/entity.py @@ -2,13 +2,12 @@ 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 +18,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 14e7103a83ff2b..743df50dfa0cdf 100644 --- a/homeassistant/components/venstar/sensor.py +++ b/homeassistant/components/venstar/sensor.py @@ -12,7 +12,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -23,8 +22,7 @@ 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 +78,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 +140,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 8e4b7e35f432ab..46e707c3c87bb3 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -9,7 +9,6 @@ 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 +22,8 @@ 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 +33,7 @@ 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 +88,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 +107,11 @@ def stop_subscription(event): 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 00780fec8ce554..38c5a3cec0b3f0 100644 --- a/homeassistant/components/vera/binary_sensor.py +++ b/homeassistant/components/vera/binary_sensor.py @@ -5,22 +5,21 @@ 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 084725f484ead2..dfb74433cfb9ec 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -14,12 +14,11 @@ 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 +28,11 @@ 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 a6e6e097b4ada2..a14f45db2dba17 100644 --- a/homeassistant/components/vera/common.py +++ b/homeassistant/components/vera/common.py @@ -13,7 +13,7 @@ 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 +22,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 +35,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 7879d103595a0a..653fa85202c009 100644 --- a/homeassistant/components/vera/config_flow.py +++ b/homeassistant/components/vera/config_flow.py @@ -13,7 +13,6 @@ from homeassistant.config_entries import ( SOURCE_USER, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -22,6 +21,7 @@ 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 +100,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 8256804b8a31a7..903eddb1687c37 100644 --- a/homeassistant/components/vera/cover.py +++ b/homeassistant/components/vera/cover.py @@ -7,22 +7,21 @@ 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/light.py b/homeassistant/components/vera/light.py index f573fcd94ea443..30ddee4909e1a1 100644 --- a/homeassistant/components/vera/light.py +++ b/homeassistant/components/vera/light.py @@ -13,23 +13,22 @@ 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 3f76f3a6106a5b..1b91b56a4cac67 100644 --- a/homeassistant/components/vera/lock.py +++ b/homeassistant/components/vera/lock.py @@ -7,12 +7,11 @@ 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 +20,11 @@ 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 0e504b12303214..ca4b4ff7c8f34b 100644 --- a/homeassistant/components/vera/scene.py +++ b/homeassistant/components/vera/scene.py @@ -7,22 +7,21 @@ 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 f69025d3ec6eb9..7d27963d8b074b 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -13,7 +13,6 @@ SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, @@ -25,7 +24,7 @@ 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 +32,11 @@ 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 67be4a7849adc0..94ceef9c4c5a1a 100644 --- a/homeassistant/components/vera/switch.py +++ b/homeassistant/components/vera/switch.py @@ -7,22 +7,21 @@ 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 e635ab712be69f..af37944ca7e246 100644 --- a/homeassistant/components/verisure/__init__.py +++ b/homeassistant/components/verisure/__init__.py @@ -14,8 +14,8 @@ 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 +27,7 @@ ] -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 +38,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 +51,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 db199b180f4958..a01f608bc42c63 100644 --- a/homeassistant/components/verisure/alarm_control_panel.py +++ b/homeassistant/components/verisure/alarm_control_panel.py @@ -10,23 +10,22 @@ 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 c42454b380a7f8..b0494c0be8a4fb 100644 --- a/homeassistant/components/verisure/binary_sensor.py +++ b/homeassistant/components/verisure/binary_sensor.py @@ -8,7 +8,6 @@ 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 +17,16 @@ 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 1f5d48ea1973a4..97deea7006cf29 100644 --- a/homeassistant/components/verisure/camera.py +++ b/homeassistant/components/verisure/camera.py @@ -8,7 +8,6 @@ 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 +18,16 @@ 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 0f1088ccb80d0c..6b4c42c8657d2e 100644 --- a/homeassistant/components/verisure/config_flow.py +++ b/homeassistant/components/verisure/config_flow.py @@ -13,12 +13,7 @@ ) 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 +25,7 @@ DOMAIN, LOGGER, ) +from .coordinator import VerisureConfigEntry class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): @@ -44,7 +40,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 5165ddc6d3dd4b..6152752d9dd451 100644 --- a/homeassistant/components/verisure/coordinator.py +++ b/homeassistant/components/verisure/coordinator.py @@ -21,13 +21,15 @@ 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 a14e6e00b98410..2758dafdf7c031 100644 --- a/homeassistant/components/verisure/diagnostics.py +++ b/homeassistant/components/verisure/diagnostics.py @@ -5,11 +5,9 @@ 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 +21,7 @@ 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 4d2229967a09eb..4ec58db08c3f01 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -8,7 +8,6 @@ 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 +26,16 @@ 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 6ed4784bffb274..a29080d16773b5 100644 --- a/homeassistant/components/verisure/sensor.py +++ b/homeassistant/components/verisure/sensor.py @@ -7,7 +7,6 @@ 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 +15,16 @@ 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 bdd933c753b068..c9d90f43704cb8 100644 --- a/homeassistant/components/verisure/switch.py +++ b/homeassistant/components/verisure/switch.py @@ -6,23 +6,22 @@ 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/vicare/const.py b/homeassistant/components/vicare/const.py index aeb52bd28ae678..bf147950b92825 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -18,6 +18,7 @@ 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 7695c304451ea1..b3a64fdad5464f 100644 --- a/homeassistant/components/vicare/diagnostics.py +++ b/homeassistant/components/vicare/diagnostics.py @@ -23,7 +23,7 @@ def dump_devices() -> list[dict[str, Any]]: """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/manifest.json b/homeassistant/components/vicare/manifest.json index eeda88cfb32a65..d2ee577f34c27c 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.59.0"] + "requirements": ["PyViCare==2.60.1"] } diff --git a/homeassistant/components/victron_ble/manifest.json b/homeassistant/components/victron_ble/manifest.json index 3a5ea6222a203e..c1969f5db37590 100644 --- a/homeassistant/components/victron_ble/manifest.json +++ b/homeassistant/components/victron_ble/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["victron-ble-ha-parser==0.6.3"] + "requirements": ["victron-ble-ha-parser==0.7.0"] } diff --git a/homeassistant/components/victron_ble/quality_scale.yaml b/homeassistant/components/victron_ble/quality_scale.yaml index 5eedb4ea163a43..13853d1d1becbb 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_gx/__init__.py b/homeassistant/components/victron_gx/__init__.py new file mode 100644 index 00000000000000..76f6e5f9dccc2a --- /dev/null +++ b/homeassistant/components/victron_gx/__init__.py @@ -0,0 +1,78 @@ +"""The victron_gx integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .hub import Hub, VictronGxConfigEntry + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.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 00000000000000..42caed422f4c32 --- /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 00000000000000..eb30ae1bbd3c6f --- /dev/null +++ b/homeassistant/components/victron_gx/config_flow.py @@ -0,0 +1,388 @@ +"""Config flow for the Victron GX integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any +from urllib.parse import urlparse + +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 00000000000000..ca806ca5249c01 --- /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 00000000000000..89259af2c554b0 --- /dev/null +++ b/homeassistant/components/victron_gx/device_tracker.py @@ -0,0 +1,99 @@ +"""Support for Victron GX device tracker.""" + +from __future__ import annotations + +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 00000000000000..9eebf602e77fc6 --- /dev/null +++ b/homeassistant/components/victron_gx/diagnostics.py @@ -0,0 +1,26 @@ +"""Diagnostics support for victron_gx.""" + +from __future__ import annotations + +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 00000000000000..c7257a62601ea1 --- /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 00000000000000..9b8e728b2aa9f0 --- /dev/null +++ b/homeassistant/components/victron_gx/hub.py @@ -0,0 +1,198 @@ +"""Main Hub class.""" + +from __future__ import annotations + +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 00000000000000..c78fa8cd29eafd --- /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 00000000000000..378a87be2a2bbf --- /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 00000000000000..2e5e179f42db9b --- /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 00000000000000..2c0a426673c9b1 --- /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 00000000000000..35a371fbe0478f --- /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 00000000000000..e3d9a9f4472e18 --- /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 00000000000000..5160e86a1ce92a --- /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 00000000000000..771b54a5a24c67 --- /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/vilfo/__init__.py b/homeassistant/components/vilfo/__init__.py index ca74e74f37abbd..95e6c8de89c31c 100644 --- a/homeassistant/components/vilfo/__init__.py +++ b/homeassistant/components/vilfo/__init__.py @@ -12,7 +12,9 @@ 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 @@ _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/sensor.py b/homeassistant/components/vilfo/sensor.py index fa2d5cae196425..7755f55a7ea83b 100644 --- a/homeassistant/components/vilfo/sensor.py +++ b/homeassistant/components/vilfo/sensor.py @@ -7,12 +7,12 @@ 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 @@ class VilfoSensorEntityDescription(SensorEntityDescription): 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/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index ecf0342ae2f86d..43a8f51fadcbee 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -31,7 +31,7 @@ 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/media_player.py b/homeassistant/components/vizio/media_player.py index 1a0b439b0e9dec..d7a3e481fbc749 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from pyvizio.api.apps import AppConfig, find_app_name from pyvizio.const import APP_HOME, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP @@ -14,10 +16,6 @@ from homeassistant.const import CONF_DEVICE_CLASS, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -122,9 +120,7 @@ def __init__( self._available_inputs: list[str] = [] self._available_apps: list[str] = [] - self._volume_step = config_entry.options[CONF_VOLUME_STEP] self._all_apps = apps_coordinator.data if apps_coordinator else None - self._conf_apps = config_entry.options.get(CONF_APPS, {}) self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get( CONF_ADDITIONAL_CONFIGS, [] ) @@ -142,6 +138,16 @@ def __init__( self._attr_device_class = device_class self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, unique_id)}) + @property + def _volume_step(self) -> int: + """Return the configured volume step.""" + return self._config_entry.options[CONF_VOLUME_STEP] + + @property + def _conf_apps(self) -> dict: + """Return the configured app filter options.""" + return self._config_entry.options.get(CONF_APPS, {}) + def _apps_list(self, apps: list[str]) -> list[str]: """Return process apps list based on configured filters.""" if self._conf_apps.get(CONF_INCLUDE): @@ -225,22 +231,6 @@ def _get_additional_app_names(self) -> list[str]: additional_app["name"] for additional_app in self._additional_app_configs ] - @staticmethod - async def _async_send_update_options_signal( - hass: HomeAssistant, config_entry: VizioConfigEntry - ) -> None: - """Send update event when Vizio config entry is updated.""" - # Move this method to component level if another entity ever gets added for a - # single config entry. - # See here: https://github.com/home-assistant/core/pull/30653#discussion_r366426121 - async_dispatcher_send(hass, config_entry.entry_id, config_entry) - - async def _async_update_options(self, config_entry: VizioConfigEntry) -> None: - """Update options if the update signal comes from this entity.""" - self._volume_step = config_entry.options[CONF_VOLUME_STEP] - # Update so that CONF_ADDITIONAL_CONFIGS gets retained for imports - self._conf_apps.update(config_entry.options.get(CONF_APPS, {})) - async def async_update_setting( self, setting_type: str, setting_name: str, new_value: int | str ) -> None: @@ -259,19 +249,10 @@ async def async_added_to_hass(self) -> None: # Process initial coordinator data self._handle_coordinator_update() - # Register callback for when config entry is updated. - self.async_on_remove( - self._config_entry.add_update_listener( - self._async_send_update_options_signal - ) - ) + async def _async_write_state(*_: Any) -> None: + self._handle_coordinator_update() - # Register callback for update event - self.async_on_remove( - async_dispatcher_connect( - self.hass, self._config_entry.entry_id, self._async_update_options - ) - ) + self.async_on_remove(self._config_entry.add_update_listener(_async_write_state)) if not (apps_coordinator := self._apps_coordinator): return diff --git a/homeassistant/components/vizio/remote.py b/homeassistant/components/vizio/remote.py new file mode 100644 index 00000000000000..5a17fc525eded1 --- /dev/null +++ b/homeassistant/components/vizio/remote.py @@ -0,0 +1,89 @@ +"""Remote platform for Vizio SmartCast devices.""" + +from __future__ import annotations + +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/strings.json b/homeassistant/components/vizio/strings.json index 04fb7e9863b438..f305f4da410d87 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/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index 2573864330d8a5..98955512460a31 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -24,7 +24,6 @@ PARALLEL_UPDATES = 0 NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] -UPTIME_DEVIATION = 60 @dataclass(frozen=True, kw_only=True) @@ -38,24 +37,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 +116,11 @@ def _line_connection( ), 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 5a32f7ecc47999..16186a36173e83 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/voip/__init__.py b/homeassistant/components/voip/__init__.py index cfdaf4dc192551..bbb3fce9a44ea8 100644 --- a/homeassistant/components/voip/__init__.py +++ b/homeassistant/components/voip/__init__.py @@ -73,6 +73,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 14333c33be58eb..05fe4d39d950cb 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -1,4 +1,5 @@ """Assist satellite entity for VoIP integration.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py index 34dac4b6068e46..5709b13bb437b3 100644 --- a/homeassistant/components/voip/binary_sensor.py +++ b/homeassistant/components/voip/binary_sensor.py @@ -27,6 +27,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/select.py b/homeassistant/components/voip/select.py index 8c9668b09ef8dd..aa033eb2165ee2 100644 --- a/homeassistant/components/voip/select.py +++ b/homeassistant/components/voip/select.py @@ -26,6 +26,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 7690b8f125c5bf..ea89fcdea12cbf 100644 --- a/homeassistant/components/voip/switch.py +++ b/homeassistant/components/voip/switch.py @@ -25,6 +25,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/volumio/__init__.py b/homeassistant/components/volumio/__init__.py index 77119b9a65e448..8977acc87feb32 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.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/const.py b/homeassistant/components/volumio/const.py index 608c029a85ea5c..51080a09254d40 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 773a125d483238..6a697e4625dcc5 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -17,29 +17,29 @@ 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/coordinator.py b/homeassistant/components/volvo/coordinator.py index db2654da179fda..2b70476c631304 100644 --- a/homeassistant/components/volvo/coordinator.py +++ b/homeassistant/components/volvo/coordinator.py @@ -263,9 +263,21 @@ async def _async_determine_api_calls( 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 77e3fdfa29d796..4a572b857690d2 100644 --- a/homeassistant/components/volvo/sensor.py +++ b/homeassistant/components/volvo/sensor.py @@ -101,6 +101,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 2c41bdb3fd25cd..445fd04cb9c048 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/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py index 16df34c1d1b268..ce1b0a73696c3e 100644 --- a/homeassistant/components/wake_on_lan/switch.py +++ b/homeassistant/components/wake_on_lan/switch.py @@ -125,6 +125,16 @@ def turn_on(self, **kwargs: Any) -> None: 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/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index ae5ed197b0757c..2014f376e9cba9 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -40,10 +40,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/water_heater/condition.py b/homeassistant/components/water_heater/condition.py index da9b8a383d96c6..6f2bb2d972e2db 100644 --- a/homeassistant/components/water_heater/condition.py +++ b/homeassistant/components/water_heater/condition.py @@ -76,6 +76,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 a200dfcf8320bc..709a3c52b57d70 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/strings.json b/homeassistant/components/water_heater/strings.json index 1e7da70662aab0..df1286d208ce6c 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -1,8 +1,10 @@ { "common": { "condition_behavior_name": "Condition passes if", + "condition_for_name": "For at least", "condition_threshold_name": "Threshold type", "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least", "trigger_threshold_name": "Threshold type" }, "conditions": { @@ -11,6 +13,9 @@ "fields": { "behavior": { "name": "[%key:component::water_heater::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::water_heater::common::condition_for_name%]" } }, "name": "Water heater is off" @@ -20,6 +25,9 @@ "fields": { "behavior": { "name": "[%key:component::water_heater::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::water_heater::common::condition_for_name%]" } }, "name": "Water heater is on" @@ -30,6 +38,9 @@ "behavior": { "name": "[%key:component::water_heater::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::water_heater::common::condition_for_name%]" + }, "operation_mode": { "description": "The operation mode to check for.", "name": "Operation mode" @@ -43,6 +54,9 @@ "behavior": { "name": "[%key:component::water_heater::common::condition_behavior_name%]" }, + "for": { + "name": "[%key:component::water_heater::common::condition_for_name%]" + }, "threshold": { "name": "[%key:component::water_heater::common::condition_threshold_name%]" } @@ -117,21 +131,6 @@ "message": "Operation mode {operation_mode} is not valid for {entity_id}. The operation list is not defined." } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "services": { "set_away_mode": { "description": "Sets the away mode of a water heater.", @@ -184,6 +183,9 @@ "behavior": { "name": "[%key:component::water_heater::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::water_heater::common::trigger_for_name%]" + }, "operation_mode": { "description": "The operation modes to trigger on.", "name": "Operation mode" @@ -206,6 +208,9 @@ "behavior": { "name": "[%key:component::water_heater::common::trigger_behavior_name%]" }, + "for": { + "name": "[%key:component::water_heater::common::trigger_for_name%]" + }, "threshold": { "name": "[%key:component::water_heater::common::trigger_threshold_name%]" } @@ -217,6 +222,9 @@ "fields": { "behavior": { "name": "[%key:component::water_heater::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::water_heater::common::trigger_for_name%]" } }, "name": "Water heater turned off" @@ -226,6 +234,9 @@ "fields": { "behavior": { "name": "[%key:component::water_heater::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::water_heater::common::trigger_for_name%]" } }, "name": "Water heater turned on" diff --git a/homeassistant/components/water_heater/trigger.py b/homeassistant/components/water_heater/trigger.py index 0a434b498b5dee..72b5efb741b54b 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 581b7dbb58c3cb..8220ea7a4cf555 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 aa79ae7efe2170..e2f874a4e9888a 100644 --- a/homeassistant/components/waterfurnace/__init__.py +++ b/homeassistant/components/waterfurnace/__init__.py @@ -14,14 +14,19 @@ 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 +39,7 @@ }, 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 +100,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 +112,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 +145,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) @@ -138,6 +159,13 @@ async def async_setup_entry( return True +async def async_unload_entry( + hass: HomeAssistant, entry: WaterFurnaceConfigEntry +) -> bool: + """Unload a WaterFurnace config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + async def async_migrate_entry( hass: HomeAssistant, entry: WaterFurnaceConfigEntry ) -> bool: diff --git a/homeassistant/components/waterfurnace/climate.py b/homeassistant/components/waterfurnace/climate.py new file mode 100644 index 00000000000000..e765dd130d8306 --- /dev/null +++ b/homeassistant/components/waterfurnace/climate.py @@ -0,0 +1,212 @@ +"""Support for WaterFurnace climate entity.""" + +from __future__ import annotations + +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/const.py b/homeassistant/components/waterfurnace/const.py index 5f12739eb05d2a..f2382045df8e98 100644 --- a/homeassistant/components/waterfurnace/const.py +++ b/homeassistant/components/waterfurnace/const.py @@ -6,3 +6,4 @@ 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 66816763232a63..ec38ab99f4602f 100644 --- a/homeassistant/components/waterfurnace/coordinator.py +++ b/homeassistant/components/waterfurnace/coordinator.py @@ -1,20 +1,63 @@ """Data update coordinator for WaterFurnace.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from datetime import datetime, timedelta import logging +import math +import random from typing import TYPE_CHECKING -from waterfurnace.waterfurnace import WaterFurnace, 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 +91,320 @@ def __init__( (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 00000000000000..176351dca07623 --- /dev/null +++ b/homeassistant/components/waterfurnace/entity.py @@ -0,0 +1,33 @@ +"""Base entity for WaterFurnace.""" + +from __future__ import annotations + +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 bcdfff1ca993c1..f94c80c56f6c3b 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/quality_scale.yaml b/homeassistant/components/waterfurnace/quality_scale.yaml index 814828dabe3c43..cbf85a7ab597af 100644 --- a/homeassistant/components/waterfurnace/quality_scale.yaml +++ b/homeassistant/components/waterfurnace/quality_scale.yaml @@ -29,7 +29,7 @@ rules: action-exceptions: status: exempt comment: This integration does not have custom service actions. - config-entry-unloading: todo + config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done entity-unavailable: done diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index 519ea0acea1008..be0a73ee09eb94 100644 --- a/homeassistant/components/waterfurnace/sensor.py +++ b/homeassistant/components/waterfurnace/sensor.py @@ -15,12 +15,11 @@ 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 +155,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 +173,8 @@ def __init__( """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/watts/coordinator.py b/homeassistant/components/watts/coordinator.py index c24853eb52c74d..02eeb7188735ed 100644 --- a/homeassistant/components/watts/coordinator.py +++ b/homeassistant/components/watts/coordinator.py @@ -192,10 +192,17 @@ def __init__( ) 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/watttime/__init__.py b/homeassistant/components/watttime/__init__.py index 6e67994b11a2a2..91da8b47708056 100644 --- a/homeassistant/components/watttime/__init__.py +++ b/homeassistant/components/watttime/__init__.py @@ -5,19 +5,18 @@ 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 +33,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 +42,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 await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - return unload_ok - -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 ad676e166c5828..7587611457f01d 100644 --- a/homeassistant/components/watttime/config_flow.py +++ b/homeassistant/components/watttime/config_flow.py @@ -9,12 +9,7 @@ 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 +26,7 @@ DOMAIN, LOGGER, ) +from .coordinator import WattTimeConfigEntry CONF_LOCATION_TYPE = "location_type" @@ -127,7 +123,7 @@ async def _async_validate_credentials( @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 a726555db538ad..6caf7e415a68e4 100644 --- a/homeassistant/components/watttime/coordinator.py +++ b/homeassistant/components/watttime/coordinator.py @@ -18,16 +18,18 @@ 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 b779b2759d1dd0..f215cc1a680e61 100644 --- a/homeassistant/components/watttime/diagnostics.py +++ b/homeassistant/components/watttime/diagnostics.py @@ -5,7 +5,6 @@ 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 +14,8 @@ ) 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 +33,13 @@ 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 23824a1369a029..3dfa28132bb198 100644 --- a/homeassistant/components/watttime/sensor.py +++ b/homeassistant/components/watttime/sensor.py @@ -10,7 +10,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -25,7 +24,7 @@ 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 +50,11 @@ 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 +72,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 4dd901e8bdcc32..83b7262bae580f 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -21,6 +21,7 @@ BooleanSelector, DurationSelector, DurationSelectorConfig, + LocationSelector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, @@ -33,6 +34,7 @@ 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 @@ 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 @@ vol.Optional(CONF_TIME_DELTA): DurationSelector( DurationSelectorConfig(allow_negative=True, enable_second=False) ), + vol.Optional(CONF_BASE_COORDINATES): LocationSelector(), } ) @@ -111,8 +115,8 @@ 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_get_travel_times_service(service: ServiceCall) -> ServiceRespons 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_get_travel_times_service(service: ServiceCall) -> ServiceRespons 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 1b97bed0a8847d..e15f65393654cf 100644 --- a/homeassistant/components/waze_travel_time/config_flow.py +++ b/homeassistant/components/waze_travel_time/config_flow.py @@ -13,12 +13,14 @@ 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 +34,7 @@ CONF_AVOID_FERRIES, CONF_AVOID_SUBSCRIPTION_ROADS, CONF_AVOID_TOLL_ROADS, + CONF_BASE_COORDINATES, CONF_DESTINATION, CONF_EXCL_FILTER, CONF_INCL_FILTER, @@ -92,6 +95,9 @@ enable_second=False, ) ), + vol.Optional(CONF_BASE_COORDINATES): LocationSelector( + LocationSelectorConfig(radius=False) + ), } ) @@ -114,18 +120,24 @@ def default_options( hass: HomeAssistant, -) -> dict[str, str | bool | list[str] | dict[str, int]]: +) -> dict[str, str | bool | list[str] | dict[str, int] | dict[str, float]]: """Get the default options.""" defaults = DEFAULT_OPTIONS.copy() if hass.config.units is US_CUSTOMARY_SYSTEM: defaults[CONF_UNITS] = IMPERIAL_UNITS + defaults[CONF_BASE_COORDINATES] = { + CONF_LATITUDE: hass.config.latitude, + CONF_LONGITUDE: hass.config.longitude, + } return defaults class WazeOptionsFlow(OptionsFlow): """Handle an options flow for Waze Travel Time.""" - async def async_step_init(self, user_input=None) -> ConfigFlowResult: + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Handle the initial step.""" if user_input is not None: if user_input.get(CONF_INCL_FILTER) is None: @@ -151,7 +163,7 @@ class WazeConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Waze Travel Time.""" VERSION = 2 - MINOR_VERSION = 2 + MINOR_VERSION = 3 @staticmethod @callback diff --git a/homeassistant/components/waze_travel_time/const.py b/homeassistant/components/waze_travel_time/const.py index 894c8a6c0a8280..590cd3d4b63943 100644 --- a/homeassistant/components/waze_travel_time/const.py +++ b/homeassistant/components/waze_travel_time/const.py @@ -2,9 +2,14 @@ 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 +38,9 @@ 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 0cf4f4ef78359c..1a6d4f3ab0c2a3 100644 --- a/homeassistant/components/waze_travel_time/coordinator.py +++ b/homeassistant/components/waze_travel_time/coordinator.py @@ -20,6 +20,7 @@ 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 @@ 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 @@ async def _async_update_data(self) -> 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 @@ async def _async_update_data(self) -> 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 @@ async def _async_update_data(self) -> WazeTravelTimeData: incl_filter, excl_filter, time_delta, + base_coordinates, ) if len(routes) < 1: travel_data = WazeTravelTimeData( @@ -249,6 +257,6 @@ async def _async_update_data(self) -> 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 c6fe4d0c9bdce4..7bee77e8e4fdf0 100644 --- a/homeassistant/components/waze_travel_time/helpers.py +++ b/homeassistant/components/waze_travel_time/helpers.py @@ -4,6 +4,7 @@ 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 @@ _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/services.yaml b/homeassistant/components/waze_travel_time/services.yaml index 6d1faf2904510a..857728ac0a17c2 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 55bb7cf995b163..221b0af5ccfbda 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 df98636d12dd5c..15aef99047e9db 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1223,7 +1223,9 @@ def __init__( 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/weatherflow/__init__.py b/homeassistant/components/weatherflow/__init__.py index 3e30d15aebee8d..9851062c5cd14f 100644 --- a/homeassistant/components/weatherflow/__init__.py +++ b/homeassistant/components/weatherflow/__init__.py @@ -21,8 +21,10 @@ 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 +58,7 @@ def _async_add_device_if_started(device: WeatherFlowDevice): 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 +72,23 @@ async def _async_handle_ha_shutdown(event: Event) -> None: 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/event.py b/homeassistant/components/weatherflow/event.py index 05f7ecc28651aa..7e2d3122249fc4 100644 --- a/homeassistant/components/weatherflow/event.py +++ b/homeassistant/components/weatherflow/event.py @@ -7,12 +7,12 @@ 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 +42,7 @@ class WeatherFlowEventEntityDescription(EventEntityDescription): 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 3d4881324ba8c6..d9a1486b415da0 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -22,7 +22,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, LIGHT_LUX, @@ -46,6 +45,7 @@ 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 +295,7 @@ def get_native_value(self, device: WeatherFlowDevice) -> datetime | StateType: 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 1b3679b91131b2..c48c50b25f6975 100644 --- a/homeassistant/components/weatherflow_cloud/__init__.py +++ b/homeassistant/components/weatherflow_cloud/__init__.py @@ -3,19 +3,19 @@ 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 +23,9 @@ 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 +75,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 +93,8 @@ async def _async_disconnect_websocket() -> None: 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/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py index 94eba6ce5a492c..645f5796603ef9 100644 --- a/homeassistant/components/weatherflow_cloud/coordinator.py +++ b/homeassistant/components/weatherflow_cloud/coordinator.py @@ -1,6 +1,9 @@ """Improved coordinator design with better type safety.""" +from __future__ import annotations + from abc import ABC, abstractmethod +from dataclasses import dataclass from datetime import timedelta from aiohttp import ClientResponseError @@ -29,13 +32,27 @@ 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 +87,7 @@ class WeatherFlowCloudUpdateCoordinatorREST( def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: WeatherFlowCloudConfigEntry, rest_api: WeatherFlowRestAPI, stations: StationsResponseREST, ) -> None: @@ -111,7 +128,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 38c73969bff9fd..60bf521d069da1 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.4.1"] + "requirements": ["weatherflow4py==1.5.4"] } diff --git a/homeassistant/components/weatherflow_cloud/sensor.py b/homeassistant/components/weatherflow_cloud/sensor.py index 68c1c62c544759..fb48a87d590bc7 100644 --- a/homeassistant/components/weatherflow_cloud/sensor.py +++ b/homeassistant/components/weatherflow_cloud/sensor.py @@ -20,10 +20,10 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfLength, + UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, @@ -34,9 +34,12 @@ 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 +238,47 @@ class WeatherFlowCloudSensorEntityDescriptionWebsocketObservation( 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 +358,15 @@ class WeatherFlowCloudSensorEntityDescriptionWebsocketObservation( 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 1114d84b858802..b9e04722ad761e 100644 --- a/homeassistant/components/weatherflow_cloud/weather.py +++ b/homeassistant/components/weatherflow_cloud/weather.py @@ -9,7 +9,6 @@ SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfPrecipitationDepth, UnitOfPressure, @@ -19,18 +18,21 @@ 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 4cbac2b32d83c1..01f53ffdf43093 100644 --- a/homeassistant/components/weatherkit/__init__.py +++ b/homeassistant/components/weatherkit/__init__.py @@ -8,28 +8,19 @@ 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 +42,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/coordinator.py b/homeassistant/components/weatherkit/coordinator.py index fd790ee230f522..2575f174f86fea 100644 --- a/homeassistant/components/weatherkit/coordinator.py +++ b/homeassistant/components/weatherkit/coordinator.py @@ -25,18 +25,20 @@ 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 b3639fa5356159..224f5986477988 100644 --- a/homeassistant/components/weatherkit/sensor.py +++ b/homeassistant/components/weatherkit/sensor.py @@ -6,15 +6,14 @@ 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 @@ 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 b57e488d06a101..0534c4207854ee 100644 --- a/homeassistant/components/weatherkit/weather.py +++ b/homeassistant/components/weatherkit/weather.py @@ -21,7 +21,6 @@ SingleCoordinatorWeatherEntity, WeatherEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfLength, UnitOfPressure, @@ -36,23 +35,18 @@ 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/webdav/helpers.py b/homeassistant/components/webdav/helpers.py index 7771439e46ecc6..d5c260535f6c2e 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 92ef59db908cc8..0b636367fac2ea 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -20,7 +20,6 @@ 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 @@ -36,7 +35,6 @@ @callback -@bind_hass def async_register( hass: HomeAssistant, domain: str, @@ -44,7 +42,7 @@ def async_register( webhook_id: str, handler: Callable[[HomeAssistant, str, Request], Awaitable[Response | None]], *, - local_only: bool | None = False, + local_only: bool = False, allowed_methods: Iterable[str] | None = None, ) -> None: """Register a webhook.""" @@ -62,6 +60,13 @@ def async_register( f"Unexpected method: {allowed_methods.difference(SUPPORTED_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] = { "domain": domain, "name": name, @@ -72,7 +77,6 @@ def async_register( @callback -@bind_hass def async_unregister(hass: HomeAssistant, webhook_id: str) -> None: """Remove a webhook.""" handlers = hass.data.setdefault(DOMAIN, {}) @@ -86,7 +90,6 @@ def async_generate_id() -> str: @callback -@bind_hass def async_generate_url( hass: HomeAssistant, webhook_id: str, @@ -117,7 +120,6 @@ 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: @@ -125,8 +127,11 @@ async def async_handle_webhook( handlers: dict[str, dict[str, Any]] = hass.data.setdefault(DOMAIN, {}) 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 else: @@ -161,11 +166,11 @@ async def async_handle_webhook( ) 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,17 +183,7 @@ 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) @@ -278,6 +273,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/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index 411ec94e8e4ed1..a188388d94a83a 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -21,7 +21,7 @@ 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 +30,6 @@ 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 +67,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 44711c2b456512..d01ca6428d6828 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -136,7 +136,7 @@ async def async_step_ssdp( 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 25c5a908fdcfc2..94b8291ab68a9b 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -9,7 +9,6 @@ DOMAIN = "webostv" PLATFORMS = [Platform.MEDIA_PLAYER] -DATA_HASS_CONFIG = "hass_config" DEFAULT_NAME = "LG webOS TV" ATTR_PAYLOAD = "payload" diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index f9bc4396e01b0d..164c5b6f16d088 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -7,7 +7,6 @@ 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 +46,6 @@ 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 ae4844cd69a007..dfb16e16e95a17 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -78,6 +78,7 @@ async def async_handle_supervisor_unix_socket(self) -> ActiveConnection: 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)") @@ -111,6 +112,7 @@ async def async_handle(self, msg: JsonValueType) -> ActiveConnection: 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 5efd6de792a584..aa1e1d7c24821a 100644 --- a/homeassistant/components/websocket_api/automation.py +++ b/homeassistant/components/websocket_api/automation.py @@ -10,7 +10,7 @@ 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 +92,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 +112,29 @@ def create(cls, component: str, target_description: dict[str, Any]) -> Self: ) 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 +237,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 +250,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 - 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) - ): + 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_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 e083a8253b14a6..5c43cd13b78921 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -865,6 +865,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 +875,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 +1028,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 +1076,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 dad8ebe5686e24..dba40e2bcdd877 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -13,6 +13,7 @@ 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 +33,15 @@ "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 +57,7 @@ class ActiveConnection: "last_id", "logger", "refresh_token_id", + "remote", "send_message", "subscriptions", "supported_features", @@ -60,6 +71,7 @@ def __init__( 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 +79,7 @@ def __init__( 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 +211,7 @@ def async_handle(self, msg: JsonValueType) -> None: 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 +275,7 @@ def _connect_closed_error( 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 +289,8 @@ def async_handle_exception(self, msg: dict[str, Any], err: Exception) -> None: 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/weheat/manifest.json b/homeassistant/components/weheat/manifest.json index 304494fcc3702e..98a147b72dd68d 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 960749a1aa127c..e9f512d0386857 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -218,7 +218,7 @@ class WeHeatSensorEntityDescription(SensorEntityDescription): 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 @@ class WeHeatSensorEntityDescription(SensorEntityDescription): 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 f98d1ab086dd8b..f75e6014356869 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 96e61dfded6770..572f6307f9b0ed 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -99,7 +99,7 @@ def _on_hass_stop(_: Event) -> None: 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/whois/__init__.py b/homeassistant/components/whois/__init__.py index 6f6462cd48b37e..4b76f8a77dfd07 100644 --- a/homeassistant/components/whois/__init__.py +++ b/homeassistant/components/whois/__init__.py @@ -2,27 +2,23 @@ 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/coordinator.py b/homeassistant/components/whois/coordinator.py index 6344e8a72e8eac..928cd1f04b2f02 100644 --- a/homeassistant/components/whois/coordinator.py +++ b/homeassistant/components/whois/coordinator.py @@ -17,13 +17,15 @@ 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 ad7d8cd7164d1d..bba1b5333b4ef6 100644 --- a/homeassistant/components/whois/diagnostics.py +++ b/homeassistant/components/whois/diagnostics.py @@ -4,19 +4,16 @@ 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 c30afbe3ac7753..9aa10def3a1981 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -14,7 +14,6 @@ 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 +29,7 @@ DOMAIN, STATUS_TYPES, ) -from .coordinator import WhoisCoordinator +from .coordinator import WhoisConfigEntry, WhoisCoordinator @dataclass(frozen=True, kw_only=True) @@ -158,11 +157,11 @@ def _get_status_type(status: str | None) -> str | None: 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 b6811190a27240..1f273addc19126 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -13,12 +13,7 @@ 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 @@ 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 abb6dd11235aa3..0b7b51f27402ae 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 c40bd5519e04f1..8040cf55fa8e09 100644 --- a/homeassistant/components/wiffi/config_flow.py +++ b/homeassistant/components/wiffi/config_flow.py @@ -12,7 +12,6 @@ from wiffi import WiffiTcpServer from homeassistant.config_entries import ( - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -20,6 +19,7 @@ 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 +31,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 f28c68dc31ca64..5d000c323f47dc 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -5,12 +5,12 @@ 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 @@ 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/manifest.json b/homeassistant/components/wiim/manifest.json index f9080754a74b58..1cd3840a33128c 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/wilight/__init__.py b/homeassistant/components/wilight/__init__.py index 5242f84ab93f41..5dd94ac44d3f64 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 2e9b92e7a216f2..d26fea34f89e8f 100644 --- a/homeassistant/components/wilight/cover.py +++ b/homeassistant/components/wilight/cover.py @@ -16,22 +16,20 @@ ) 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 6a22da5879e25d..bbb1008457d953 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -17,7 +17,6 @@ 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 +24,19 @@ 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 7df0eb1a4c6929..955b14eca671ba 100644 --- a/homeassistant/components/wilight/light.py +++ b/homeassistant/components/wilight/light.py @@ -13,13 +13,11 @@ 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 +40,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 6e71649d8fca43..d3a0dd3a4bb924 100644 --- a/homeassistant/components/wilight/parent_device.py +++ b/homeassistant/components/wilight/parent_device.py @@ -16,11 +16,13 @@ _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/switch.py b/homeassistant/components/wilight/switch.py index 148ea65dd945ae..69e102cc8229a3 100644 --- a/homeassistant/components/wilight/switch.py +++ b/homeassistant/components/wilight/switch.py @@ -9,14 +9,12 @@ 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 +74,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/conditions.yaml b/homeassistant/components/window/conditions.yaml index 327fb2826a8d90..34275f9b42b7b9 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 5f8de98998f72a..30ff7ce67341e4 100644 --- a/homeassistant/components/window/strings.json +++ b/homeassistant/components/window/strings.json @@ -1,7 +1,9 @@ { "common": { "condition_behavior_name": "Condition passes if", - "trigger_behavior_name": "Trigger when" + "condition_for_name": "For at least", + "trigger_behavior_name": "Trigger when", + "trigger_for_name": "For at least" }, "conditions": { "is_closed": { @@ -9,6 +11,9 @@ "fields": { "behavior": { "name": "[%key:component::window::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::window::common::condition_for_name%]" } }, "name": "Window is closed" @@ -18,26 +23,14 @@ "fields": { "behavior": { "name": "[%key:component::window::common::condition_behavior_name%]" + }, + "for": { + "name": "[%key:component::window::common::condition_for_name%]" } }, "name": "Window is open" } }, - "selector": { - "condition_behavior": { - "options": { - "all": "All", - "any": "Any" - } - }, - "trigger_behavior": { - "options": { - "any": "Any", - "first": "First", - "last": "Last" - } - } - }, "title": "Window", "triggers": { "closed": { @@ -45,6 +38,9 @@ "fields": { "behavior": { "name": "[%key:component::window::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::window::common::trigger_for_name%]" } }, "name": "Window closed" @@ -54,6 +50,9 @@ "fields": { "behavior": { "name": "[%key:component::window::common::trigger_behavior_name%]" + }, + "for": { + "name": "[%key:component::window::common::trigger_for_name%]" } }, "name": "Window opened" diff --git a/homeassistant/components/window/triggers.yaml b/homeassistant/components/window/triggers.yaml index 4d770a85d2ca58..3663e0f61253d4 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/withings/__init__.py b/homeassistant/components/withings/__init__.py index f687979eef896d..79cfc295c4905f 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -44,6 +44,7 @@ ) 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 +153,12 @@ async def _refresh_token() -> str: 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/entity.py b/homeassistant/components/withings/entity.py index 5c548fdb260d8f..5781b85990e324 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -31,7 +31,6 @@ def __init__( 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/wiz/strings.json b/homeassistant/components/wiz/strings.json index 5569cb422d409d..b3bd6120fe0107 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/wled/icons.json b/homeassistant/components/wled/icons.json index a4e8fa1092a84c..3cbed36792b0b2 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 244837bab207fc..4cfe64f66989e7 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -188,12 +188,11 @@ def brightness(self) -> int | None: # 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 b14c5df25ef35b..37352376050de5 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 a91d83a3ee9ba4..c9a3a6338cb83f 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -54,7 +54,7 @@ class WLEDNumberEntityDescription(NumberEntityDescription): 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/strings.json b/homeassistant/components/wled/strings.json index aa4303c6709413..2329636d068be3 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -115,12 +115,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 1e228b0a91ede8..e18a32381f9948 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -2,10 +2,14 @@ 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 +22,36 @@ 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 +178,35 @@ async def async_turn_on(self, **kwargs: Any) -> None: 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 +218,26 @@ def available(self) -> bool: @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)) + + 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_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_turn_on(self, **kwargs: Any) -> None: + """Turn on the WLED segment switch.""" + await self._async_set_state(True) @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) + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the WLED segment switch.""" + await self._async_set_state(False) @callback @@ -200,11 +253,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/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py index 3fb733e650be72..0ac1577a3b2765 100644 --- a/homeassistant/components/wolflink/__init__.py +++ b/homeassistant/components/wolflink/__init__.py @@ -12,22 +12,15 @@ 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 b752b00790f1e3..7fda87282bafa7 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 24e557a9bf5002..d143273a6aa28b 100644 --- a/homeassistant/components/wolflink/coordinator.py +++ b/homeassistant/components/wolflink/coordinator.py @@ -16,16 +16,18 @@ _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 @@ def __init__( 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 @@ async def _async_update_data(self) -> 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 0d8e6603602029..e85d20e3931e14 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 0205ce793edf13..b5de6b305bc718 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -26,7 +26,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, REVOLUTIONS_PER_MINUTE, @@ -43,8 +42,8 @@ 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 +132,15 @@ class WolflinkSensorEntityDescription(SensorEntityDescription): 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/manifest.json b/homeassistant/components/workday/manifest.json index 84918b2bad45e4..09d58507668adf 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.94"] + "requirements": ["holidays==0.95"] } diff --git a/homeassistant/components/ws66i/__init__.py b/homeassistant/components/ws66i/__init__.py index 23a27adeb691bb..1cd62d09e5b845 100644 --- a/homeassistant/components/ws66i/__init__.py +++ b/homeassistant/components/ws66i/__init__.py @@ -6,14 +6,13 @@ 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 +55,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 +85,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 +107,10 @@ def shutdown(event): 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/media_player.py b/homeassistant/components/ws66i/media_player.py index 36b199a1c9c064..9d62ea2f94ca92 100644 --- a/homeassistant/components/ws66i/media_player.py +++ b/homeassistant/components/ws66i/media_player.py @@ -7,7 +7,6 @@ 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 .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 3c46d07179050c..fa9be7d639af84 100644 --- a/homeassistant/components/ws66i/models.py +++ b/homeassistant/components/ws66i/models.py @@ -6,6 +6,8 @@ from pyws66i import WS66i +from homeassistant.config_entries import ConfigEntry + from .coordinator import Ws66iDataUpdateCoordinator @@ -27,3 +29,6 @@ class Ws66iData: sources: SourceRep coordinator: Ws66iDataUpdateCoordinator zones: list[int] + + +type Ws66iConfigEntry = ConfigEntry[Ws66iData] diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py index b32d6e82f811c2..e5a734d0dbe434 100644 --- a/homeassistant/components/wyoming/__init__.py +++ b/homeassistant/components/wyoming/__init__.py @@ -4,7 +4,6 @@ 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 +13,7 @@ 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 +41,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 +49,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 +78,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 5cea8fb1655220..40d1b0a5a8b8a5 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -34,16 +34,15 @@ 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 +67,11 @@ 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 +96,7 @@ def __init__( hass: HomeAssistant, service: WyomingService, device: SatelliteDevice, - config_entry: ConfigEntry, + config_entry: WyomingConfigEntry, ) -> None: """Initialize an Assist satellite.""" WyomingSatelliteEntity.__init__(self, device) diff --git a/homeassistant/components/wyoming/binary_sensor.py b/homeassistant/components/wyoming/binary_sensor.py index a3652e7f70f300..ec8fe7d0787462 100644 --- a/homeassistant/components/wyoming/binary_sensor.py +++ b/homeassistant/components/wyoming/binary_sensor.py @@ -2,30 +2,24 @@ 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/conversation.py b/homeassistant/components/wyoming/conversation.py index 70d0ddc3bb6ce3..2b1edc43afaf31 100644 --- a/homeassistant/components/wyoming/conversation.py +++ b/homeassistant/components/wyoming/conversation.py @@ -10,7 +10,6 @@ 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 .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/models.py b/homeassistant/components/wyoming/models.py index b819d06f91606f..f41ad9469d8c51 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 96ec587754505c..dd07dbca6bed0a 100644 --- a/homeassistant/components/wyoming/number.py +++ b/homeassistant/components/wyoming/number.py @@ -2,19 +2,15 @@ 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 +19,11 @@ 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 b3af22a4c16ed1..4417c96574d519 100644 --- a/homeassistant/components/wyoming/select.py +++ b/homeassistant/components/wyoming/select.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Final +from typing import Final from homeassistant.components.assist_pipeline import ( AssistPipelineSelect, @@ -10,7 +10,6 @@ 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 +18,7 @@ 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 +32,11 @@ 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 bc2fec2db2f360..3b86fa6de09968 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -8,25 +8,24 @@ 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 9eb91d5ef397a4..06fd98ffadf3d9 100644 --- a/homeassistant/components/wyoming/switch.py +++ b/homeassistant/components/wyoming/switch.py @@ -2,29 +2,25 @@ 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 79b98fed7286a9..5c03a8aaa79ffb 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -18,25 +18,24 @@ ) 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 25ab2f43a01f08..29027593dede6f 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -9,25 +9,23 @@ 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 613238c302a3c5..66fb2e1eafa254 100644 --- a/homeassistant/components/wyoming/websocket_api.py +++ b/homeassistant/components/wyoming/websocket_api.py @@ -9,7 +9,7 @@ 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/xbox/manifest.json b/homeassistant/components/xbox/manifest.json index 7be5e252ea59fd..cae0c031f421f4 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/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 6e4d143d84e8f3..931de28dbae88b 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 @@ 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 remove_device_service(call: ServiceCall) -> None: 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 @@ def stop_xiaomi(event): 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 544cd6f7e318d3..c16f91dad0bc21 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -9,13 +9,12 @@ 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 @@ 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 d137941d6141f5..6b410d0f566a56 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 ebab334425049b..676d946104faaf 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -5,11 +5,10 @@ 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 @@ 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 @@ def __init__( 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 3f640b675166fa..de7d0dfa7dae9a 100644 --- a/homeassistant/components/xiaomi_aqara/entity.py +++ b/homeassistant/components/xiaomi_aqara/entity.py @@ -6,7 +6,6 @@ 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.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 @@ def __init__( 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 585ab39ba6bd1d..359929de185f52 100644 --- a/homeassistant/components/xiaomi_aqara/light.py +++ b/homeassistant/components/xiaomi_aqara/light.py @@ -13,12 +13,11 @@ 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 @@ 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 @@ def __init__( 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 86d20a7024f327..39f7ef442ccec6 100644 --- a/homeassistant/components/xiaomi_aqara/lock.py +++ b/homeassistant/components/xiaomi_aqara/lock.py @@ -7,12 +7,11 @@ 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 +26,11 @@ 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 +46,7 @@ def __init__( 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 5a344fcf665c39..576178a9c773d9 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -13,7 +13,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, LIGHT_LUX, @@ -25,7 +24,8 @@ 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 +87,12 @@ 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 +173,7 @@ def __init__( 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 69cba6491cdb71..ff1232db898fe0 100644 --- a/homeassistant/components/xiaomi_aqara/switch.py +++ b/homeassistant/components/xiaomi_aqara/switch.py @@ -6,11 +6,10 @@ 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 @@ 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 @@ def __init__( 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/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 8dfbe0a1c74bd8..156a9f9e6c4dbb 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 3b2fcddc197191..baea9c7095315d 100644 --- a/homeassistant/components/xiaomi_ble/sensor.py +++ b/homeassistant/components/xiaomi_ble/sensor.py @@ -145,10 +145,9 @@ 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/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index edc124890c53f4..38ff3a982893c7 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -8,13 +8,12 @@ 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 +37,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 +59,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 +69,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/coordinator.py b/homeassistant/components/yamaha_musiccast/coordinator.py index 13afbe3aa5e7a3..eae559faac6898 100644 --- a/homeassistant/components/yamaha_musiccast/coordinator.py +++ b/homeassistant/components/yamaha_musiccast/coordinator.py @@ -22,14 +22,19 @@ 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/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 33fb32fffa158f..e0b17f57dc5b73 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -21,7 +21,6 @@ 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 +37,7 @@ MEDIA_CLASS_MAPPING, NULL_GROUP, ) -from .coordinator import MusicCastDataUpdateCoordinator +from .coordinator import MusicCastConfigEntry from .entity import MusicCastDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -55,11 +54,11 @@ 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 +613,14 @@ def is_client(self) -> bool: 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 0de14ef142d4a3..390361448ddb91 100644 --- a/homeassistant/components/yamaha_musiccast/number.py +++ b/homeassistant/components/yamaha_musiccast/number.py @@ -5,22 +5,20 @@ 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 133cb4c4d7b578..16c236f7a7016e 100644 --- a/homeassistant/components/yamaha_musiccast/select.py +++ b/homeassistant/components/yamaha_musiccast/select.py @@ -5,22 +5,21 @@ 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 148f09930f3fb0..4506fe5b48eabf 100644 --- a/homeassistant/components/yamaha_musiccast/switch.py +++ b/homeassistant/components/yamaha_musiccast/switch.py @@ -5,22 +5,20 @@ 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/yardian/__init__.py b/homeassistant/components/yardian/__init__.py index 3f0bf7c32d9f50..72e6bc4a2f5ac9 100644 --- a/homeassistant/components/yardian/__init__.py +++ b/homeassistant/components/yardian/__init__.py @@ -4,13 +4,11 @@ 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 +17,7 @@ ] -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 +27,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 12edcd02fb9daf..7552496c8d2d39 100644 --- a/homeassistant/components/yardian/binary_sensor.py +++ b/homeassistant/components/yardian/binary_sensor.py @@ -10,14 +10,12 @@ 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 +75,11 @@ def value(coordinator: YardianUpdateCoordinator) -> bool | None: 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/coordinator.py b/homeassistant/components/yardian/coordinator.py index 8028377daf4da0..897a75eaba49df 100644 --- a/homeassistant/components/yardian/coordinator.py +++ b/homeassistant/components/yardian/coordinator.py @@ -40,15 +40,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 3be0ddee76b326..979b9ee675e45b 100644 --- a/homeassistant/components/yardian/sensor.py +++ b/homeassistant/components/yardian/sensor.py @@ -11,7 +11,6 @@ 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 +18,7 @@ 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 +54,7 @@ def _zone_delay_value(coordinator: YardianUpdateCoordinator) -> StateType: 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 +70,7 @@ def _zone_delay_value(coordinator: YardianUpdateCoordinator) -> StateType: 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 +80,7 @@ def _zone_delay_value(coordinator: YardianUpdateCoordinator) -> StateType: 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 +90,11 @@ def _zone_delay_value(coordinator: YardianUpdateCoordinator) -> StateType: 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 ba98fa2aaaa01a..be1c5f4a6ea6a9 100644 --- a/homeassistant/components/yardian/switch.py +++ b/homeassistant/components/yardian/switch.py @@ -7,15 +7,14 @@ 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 +24,11 @@ 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 cb24edae1fda77..dc9f359f8cd33a 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -37,9 +37,7 @@ 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 +54,8 @@ from .device import YeelightDevice, async_format_id from .scanner import YeelightScanner +type YeelightConfigEntry = ConfigEntry[YeelightDevice] + _LOGGER = logging.getLogger(__name__) @@ -116,10 +116,7 @@ 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 +138,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 +156,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 +201,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 +233,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 9d9657892f0484..5da8e904523760 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 @@ 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 cc3ab35f68490a..2a985fb3f9b511 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -13,7 +13,6 @@ from homeassistant.components import onboarding from homeassistant.config_entries import ( - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -28,6 +27,7 @@ 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 +62,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 +145,7 @@ async def _async_handle_discovery(self) -> ConfigFlowResult: 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 e9ba80bca95e69..2ef3c2471fc2e8 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_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/light.py b/homeassistant/components/yeelight/light.py index b2eaed79917e38..eb1ef7881dd5df 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -39,7 +39,7 @@ 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 +51,8 @@ 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 +217,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 +277,13 @@ async def _async_wrap( 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/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 54a903302d3f5e..3243330f3b7315 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass from datetime import timedelta from typing import Any @@ -13,7 +12,7 @@ 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 +30,7 @@ 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 +57,20 @@ 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 +100,6 @@ def on_message(self, device: YoLinkDevice, msg_data: dict[str, Any]) -> None: 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 +108,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 +160,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 +188,8 @@ async def async_yolink_unload(event) -> None: 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 cfec02ca3e2ce1..7d64cd72fbeeda 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -23,16 +23,11 @@ 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 +176,11 @@ def is_leak_sensor_state_available(device: YoLinkDevice, data: dict) -> bool: 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 +203,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 65253094fa91e7..4f719c7e64c304 100644 --- a/homeassistant/components/yolink/climate.py +++ b/homeassistant/components/yolink/climate.py @@ -19,13 +19,11 @@ 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 +44,11 @@ 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 +64,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/coordinator.py b/homeassistant/components/yolink/coordinator.py index 2c914e84a08c6b..1f936caf973afc 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from datetime import UTC, datetime, timedelta import logging from typing import Any @@ -10,6 +11,7 @@ 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 +24,26 @@ _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 b1cfc3681cc4ad..bd15c2311da526 100644 --- a/homeassistant/components/yolink/cover.py +++ b/homeassistant/components/yolink/cover.py @@ -12,22 +12,20 @@ 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 +42,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/entity.py b/homeassistant/components/yolink/entity.py index ecc42ad1a0eb0b..d06dd6f14f7f67 100644 --- a/homeassistant/components/yolink/entity.py +++ b/homeassistant/components/yolink/entity.py @@ -7,13 +7,12 @@ 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 +22,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 54470673fa5279..3fbd069a474cba 100644 --- a/homeassistant/components/yolink/light.py +++ b/homeassistant/components/yolink/light.py @@ -8,22 +8,20 @@ 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 +39,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 5e244dd08f21f3..24c4711d47f98d 100644 --- a/homeassistant/components/yolink/lock.py +++ b/homeassistant/components/yolink/lock.py @@ -8,22 +8,20 @@ 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 +38,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 87dbb9282bf715..4af9013dc4cbd4 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 c643a20d0ea728..c9253cc8518a8f 100644 --- a/homeassistant/components/yolink/number.py +++ b/homeassistant/components/yolink/number.py @@ -15,12 +15,10 @@ 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 +63,11 @@ def get_volume_value(state: dict[str, Any]) -> int | None: 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 +92,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 030b193edff5aa..86bc8e142ad8a7 100644 --- a/homeassistant/components/yolink/select.py +++ b/homeassistant/components/yolink/select.py @@ -12,12 +12,10 @@ 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 +64,11 @@ async def set_sprinker_mode_fn(coordinator: YoLinkCoordinator, option: str) -> b 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 +85,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 67a9dd64a04274..acba49b1796ba3 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -44,7 +44,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, @@ -76,9 +75,8 @@ 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 +421,11 @@ def parse_data_temperature(device: YoLinkDevice, data: dict) -> float | None: 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 +450,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 5bc5f2f9660559..41ce171439b160 100644 --- a/homeassistant/components/yolink/services.py +++ b/homeassistant/components/yolink/services.py @@ -45,7 +45,7 @@ async def handle_speaker_hub_play_call(service_call: ServiceCall) -> 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 9ff76b29a9a06b..8fc5c3aca480c7 100644 --- a/homeassistant/components/yolink/siren.py +++ b/homeassistant/components/yolink/siren.py @@ -15,12 +15,10 @@ 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 +43,11 @@ class YoLinkSirenEntityDescription(SirenEntityDescription): 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 +70,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 999ec6c1abac21..2b9ca1a2e610d9 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -21,12 +21,11 @@ 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 +120,11 @@ class YoLinkSwitchEntityDescription(SwitchEntityDescription): 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 +145,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 1683f600715ca0..dae30bec624114 100644 --- a/homeassistant/components/yolink/valve.py +++ b/homeassistant/components/yolink/valve.py @@ -22,13 +22,12 @@ 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 +108,11 @@ def sprinkler_valve_available(device: YoLinkDevice, data: dict[str, Any]) -> boo 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 +133,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 af14d597b793ac..a2e61a324b2557 100644 --- a/homeassistant/components/youless/__init__.py +++ b/homeassistant/components/youless/__init__.py @@ -5,20 +5,18 @@ 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/coordinator.py b/homeassistant/components/youless/coordinator.py index 81e4b3a4c76c75..a798a80798912e 100644 --- a/homeassistant/components/youless/coordinator.py +++ b/homeassistant/components/youless/coordinator.py @@ -11,14 +11,16 @@ _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 6a1e0ceea0a777..d0ecdf5cd1a6a3 100644 --- a/homeassistant/components/youless/sensor.py +++ b/homeassistant/components/youless/sensor.py @@ -13,7 +13,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE, UnitOfElectricCurrent, @@ -26,8 +25,8 @@ 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 +302,13 @@ class YouLessSensorEntityDescription(SensorEntityDescription): 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 32863f5a77260d..78d85b289a55c7 100644 --- a/homeassistant/components/youtube/__init__.py +++ b/homeassistant/components/youtube/__init__.py @@ -4,7 +4,6 @@ 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 +15,13 @@ ) 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 +48,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 76d74965b34f24..ee8369cbcaffb8 100644 --- a/homeassistant/components/youtube/config_flow.py +++ b/homeassistant/components/youtube/config_flow.py @@ -10,12 +10,7 @@ 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 +28,7 @@ DOMAIN, LOGGER, ) +from .coordinator import YouTubeConfigEntry class OAuth2FlowHandler( @@ -50,7 +46,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 da5a554f364594..e410ee8735647f 100644 --- a/homeassistant/components/youtube/const.py +++ b/homeassistant/components/youtube/const.py @@ -9,8 +9,6 @@ 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 476e5bb402262f..76b01ed951ac92 100644 --- a/homeassistant/components/youtube/coordinator.py +++ b/homeassistant/components/youtube/coordinator.py @@ -14,7 +14,7 @@ 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 +29,19 @@ 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 9a898b7e2de7a7..001edb4ead9cab 100644 --- a/homeassistant/components/youtube/diagnostics.py +++ b/homeassistant/components/youtube/diagnostics.py @@ -4,20 +4,17 @@ 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/manifest.json b/homeassistant/components/youtube/manifest.json index 83734318bcd09a..a96293902b8fa8 100644 --- a/homeassistant/components/youtube/manifest.json +++ b/homeassistant/components/youtube/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/youtube", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["youtubeaio==2.1.1"] + "requirements": ["youtubeaio==2.1.2"] } diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py index 224ace3d405398..bc63c1d983e598 100644 --- a/homeassistant/components/youtube/sensor.py +++ b/homeassistant/components/youtube/sensor.py @@ -11,13 +11,11 @@ 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 +24,8 @@ ATTR_TITLE, ATTR_TOTAL_VIEWS, ATTR_VIDEO_ID, - COORDINATOR, - DOMAIN, ) +from .coordinator import YouTubeConfigEntry from .entity import YouTubeChannelEntity @@ -79,13 +76,11 @@ class YouTubeSensorEntityDescription(SensorEntityDescription): 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/zamg/__init__.py b/homeassistant/components/zamg/__init__.py index f6241e53fbe8fa..21e726e8bdd79c 100644 --- a/homeassistant/components/zamg/__init__.py +++ b/homeassistant/components/zamg/__init__.py @@ -2,18 +2,17 @@ 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 +21,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 +29,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/coordinator.py b/homeassistant/components/zamg/coordinator.py index a88c97ad267eb2..e60cb646166945 100644 --- a/homeassistant/components/zamg/coordinator.py +++ b/homeassistant/components/zamg/coordinator.py @@ -12,11 +12,13 @@ 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 +26,7 @@ def __init__( 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 6caa0741c1baa7..8b9bd424a14655 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -11,7 +11,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, PERCENTAGE, @@ -36,7 +35,7 @@ DOMAIN, MANUFACTURER_URL, ) -from .coordinator import ZamgDataUpdateCoordinator +from .coordinator import ZamgConfigEntry, ZamgDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -174,11 +173,11 @@ class ZamgSensorEntityDescription(SensorEntityDescription): 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 85301d6186e7da..551ea9b8b3c3e9 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -3,7 +3,6 @@ from __future__ import annotations from homeassistant.components.weather import WeatherEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( UnitOfPrecipitationDepth, UnitOfPressure, @@ -16,16 +15,16 @@ 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/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 82317d06205746..72bfa3f814cfca 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -22,13 +22,12 @@ 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 +67,11 @@ ) -@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 +88,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 +103,7 @@ async def _async_stop_zeroconf(_event: Event) -> None: # 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 6267d18642cb8b..ae279e5107796f 100644 --- a/homeassistant/components/zeroconf/const.py +++ b/homeassistant/components/zeroconf/const.py @@ -1,7 +1,20 @@ """Zeroconf constants.""" +from __future__ import annotations + +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 1158f8a2fdb29e..b2cd76aa83397f 100644 --- a/homeassistant/components/zeroconf/discovery.py +++ b/homeassistant/components/zeroconf/discovery.py @@ -24,12 +24,9 @@ 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 +50,6 @@ 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/websocket_api.py b/homeassistant/components/zeroconf/websocket_api.py index 3a1881e6f4e985..963fdcd4c9ce7f 100644 --- a/homeassistant/components/zeroconf/websocket_api.py +++ b/homeassistant/components/zeroconf/websocket_api.py @@ -17,8 +17,8 @@ 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 +157,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 953720038ccd79..a1a2d829f43494 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 19175ae3084d3c..b65b045fe58364 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -1,4 +1,5 @@ """Zerproc light platform.""" +# pylint: disable=hass-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern from __future__ import annotations diff --git a/homeassistant/components/zestimate/manifest.json b/homeassistant/components/zestimate/manifest.json index 0b1039186b74bb..ad6abb0cd038c0 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/zeversolar/__init__.py b/homeassistant/components/zeversolar/__init__.py index cb48579367b62e..f76656a02c21e9 100644 --- a/homeassistant/components/zeversolar/__init__.py +++ b/homeassistant/components/zeversolar/__init__.py @@ -2,25 +2,22 @@ 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/coordinator.py b/homeassistant/components/zeversolar/coordinator.py index ec68cf4b56f471..d06284d9d8cbdc 100644 --- a/homeassistant/components/zeversolar/coordinator.py +++ b/homeassistant/components/zeversolar/coordinator.py @@ -16,13 +16,15 @@ _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 6e6ed262f517a5..b1cbf3e8a4bbe6 100644 --- a/homeassistant/components/zeversolar/diagnostics.py +++ b/homeassistant/components/zeversolar/diagnostics.py @@ -4,21 +4,18 @@ 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/sensor.py b/homeassistant/components/zeversolar/sensor.py index 330e5bb72d80c8..a75867299adabf 100644 --- a/homeassistant/components/zeversolar/sensor.py +++ b/homeassistant/components/zeversolar/sensor.py @@ -13,13 +13,11 @@ 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 +51,11 @@ class ZeversolarEntityDescription(SensorEntityDescription): 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/config_flow.py b/homeassistant/components/zha/config_flow.py index 54034fc6b13409..12472fc990da9b 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -9,7 +9,6 @@ from enum import StrEnum import json import logging -import os from typing import Any import voluptuous as vol @@ -20,13 +19,9 @@ 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 +37,12 @@ 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 +59,6 @@ _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 +101,6 @@ 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 +126,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 +189,9 @@ async def async_step_choose_serial_port( 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 +200,25 @@ async def async_step_choose_serial_port( 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 +233,7 @@ async def async_step_manual_pick_radio_type( ) 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 +256,6 @@ async def async_step_manual_port_config( 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 +865,11 @@ async def async_step_confirm( 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 +895,17 @@ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResu 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 +964,6 @@ async def async_step_zeroconf( ) 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 +996,6 @@ async def async_step_hardware( 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 +1016,6 @@ async def _async_create_radio_entry(self) -> ConfigFlowResult: 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 +1030,7 @@ async def _async_create_radio_entry(self) -> ConfigFlowResult: ) 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 +1046,6 @@ def __init__(self, config_entry: ConfigEntry) -> None: 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/entity.py b/homeassistant/components/zha/entity.py index f3a0d0584c2bec..190a3748e8f8bc 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -27,7 +27,12 @@ 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 +168,16 @@ async def async_added_to_hass(self) -> None: 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 +204,7 @@ async def async_will_remove_from_hass(self) -> None: 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/helpers.py b/homeassistant/components/zha/helpers.py index 436e95f8ef9a64..09705321b41b1c 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -77,6 +77,8 @@ from zha.zigbee.device import ( ClusterHandlerConfigurationComplete, Device, + DeviceEntityAddedEvent, + DeviceEntityRemovedEvent, DeviceFirmwareInfoUpdatedEvent, ZHAEvent, ) @@ -206,6 +208,7 @@ 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 +498,41 @@ def handle_zha_channel_bind(self, event: ClusterBindEvent) -> None: }, ) + @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 +852,12 @@ def get_entity_reference(self, entity_id: str) -> EntityReference | None: 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/manifest.json b/homeassistant/components/zha/manifest.json index 9c745e0fe0c991..80d044221df21a 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.2", "serialx==0.6.2"], + "requirements": ["zha==1.3.0"], "usb": [ { "description": "*2652*", diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 7bfeda2c215a6a..0b8b3d6b4efb39 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -409,7 +409,7 @@ async def async_initiate_migration(self, data: dict[str, Any]) -> bool: create_backup=True ) break - except OSError as err: + except (OSError, HomeAssistantError) as err: if retry >= BACKUP_RETRIES - 1: raise @@ -450,7 +450,7 @@ async def async_finish_migration(self) -> None: 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/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 431a567e408099..592c531581c16d 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -47,7 +47,6 @@ 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 +634,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 +661,8 @@ def async_cleanup() -> None: 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/zimi/manifest.json b/homeassistant/components/zimi/manifest.json index eea74330970025..fef7b764a995da 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 8b8b85c71f41d8..a031bfc48aa8f0 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/zinvolt/__init__.py b/homeassistant/components/zinvolt/__init__.py index ff8b7fdfe90c32..71b9d97f7e7196 100644 --- a/homeassistant/components/zinvolt/__init__.py +++ b/homeassistant/components/zinvolt/__init__.py @@ -17,6 +17,7 @@ _PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, ] diff --git a/homeassistant/components/zinvolt/select.py b/homeassistant/components/zinvolt/select.py new file mode 100644 index 00000000000000..efa2bcbba1f76e --- /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 d4bc22a1247fde..4612949a7a959f 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/zone/__init__.py b/homeassistant/components/zone/__init__.py index b0d7a6ba8d1819..410194695e8b09 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -46,7 +46,6 @@ 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 +112,21 @@ def empty_value(value: Any) -> Any: 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 +136,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 +156,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 +177,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 ee3f286c6601b3..bb9c1cb2fd02fb 100644 --- a/homeassistant/components/zone/condition.py +++ b/homeassistant/components/zone/condition.py @@ -22,7 +22,6 @@ from homeassistant.helpers.automation import move_top_level_schema_fields_to_options from homeassistant.helpers.condition import ( Condition, - ConditionChecker, ConditionCheckParams, ConditionConfig, ) @@ -117,44 +116,39 @@ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: super().__init__(hass, config) assert config.options is not None self._options = config.options - - 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 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}" - ), - ) + self._entity_ids = self._options.get(CONF_ENTITY_ID, []) + self._zone_entity_ids = self._options.get(CONF_ZONE, []) + + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: + """Test if condition.""" + errors = [] + + 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 - - # Raise the errors only if no definitive result was found - if errors and not all_ok: - raise ConditionErrorContainer("zone", errors=errors) + if not entity_ok: + all_ok = False - return all_ok + # Raise the errors only if no definitive result was found + if errors and not all_ok: + raise ConditionErrorContainer("zone", errors=errors) - return if_in_zone + return all_ok CONDITIONS: dict[str, type[Condition]] = { diff --git a/homeassistant/components/zwave_js/addon.py b/homeassistant/components/zwave_js/addon.py index 12d81146c03abf..8c167979cf5ec5 100644 --- a/homeassistant/components/zwave_js/addon.py +++ b/homeassistant/components/zwave_js/addon.py @@ -2,17 +2,69 @@ from __future__ import annotations -from homeassistant.components.hassio import AddonManager +from typing import Any + +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 2388cc085faf60..835ba41b433b67 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1753,16 +1753,21 @@ def forward_event(key: str, event: dict) -> None: 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 9ec546be756299..afbe308e1b84d8 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -47,6 +47,7 @@ is_opening_state_notification_value, ) from .models import ( + FirmwareVersionRange, NewZWaveDiscoverySchema, ValueType, ZwaveDiscoveryInfo, @@ -1346,6 +1347,38 @@ def __init__( ), 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/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index b22d1af3c56543..899e6b1faabe1d 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -11,7 +11,6 @@ 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 +159,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 +187,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 2615bfc72b305f..0ec138b81f2210 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 @@ 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/device_action.py b/homeassistant/components/zwave_js/device_action.py index bec9c8e55ab789..a9b2c993c1f8c2 100644 --- a/homeassistant/components/zwave_js/device_action.py +++ b/homeassistant/components/zwave_js/device_action.py @@ -30,7 +30,7 @@ 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 +122,7 @@ 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 +334,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_condition.py b/homeassistant/components/zwave_js/device_condition.py index 8a50c838eec2be..25f094179a4d9e 100644 --- a/homeassistant/components/zwave_js/device_condition.py +++ b/homeassistant/components/zwave_js/device_condition.py @@ -15,7 +15,7 @@ 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 +65,7 @@ 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 +221,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 bfc37328bfb497..6239456bfe378b 100644 --- a/homeassistant/components/zwave_js/device_trigger.py +++ b/homeassistant/components/zwave_js/device_trigger.py @@ -31,7 +31,7 @@ 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 +91,7 @@ # 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 +162,7 @@ # 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 +558,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/helpers.py b/homeassistant/components/zwave_js/helpers.py index 6ca88e48ac7245..1b2c7a1422c4b8 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -572,12 +572,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/manifest.json b/homeassistant/components/zwave_js/manifest.json index cdef87d987a650..0abbd85e56be86 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.68.0"], "usb": [ { "known_devices": ["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"], diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index 22f8ab78dc7763..c711b3e968ff83 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -51,8 +51,8 @@ _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_me/__init__.py b/homeassistant/components/zwave_me/__init__.py index 36ee62eec531e8..ae496b7d0516d3 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 8563ef76ce1a0a..17c46b7e7da716 100644 --- a/homeassistant/components/zwave_me/binary_sensor.py +++ b/homeassistant/components/zwave_me/binary_sensor.py @@ -9,13 +9,12 @@ 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 +31,22 @@ 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 27d95a14199fbb..20584998942ae5 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 @@ 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 d54cc6a931042b..9e33ea687949b6 100644 --- a/homeassistant/components/zwave_me/climate.py +++ b/homeassistant/components/zwave_me/climate.py @@ -11,13 +11,13 @@ 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 +27,7 @@ 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 +35,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/controller.py b/homeassistant/components/zwave_me/controller.py new file mode 100644 index 00000000000000..9e68b16883772b --- /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 3ae8ec894e1583..26207f269c5e79 100644 --- a/homeassistant/components/zwave_me/cover.py +++ b/homeassistant/components/zwave_me/cover.py @@ -9,12 +9,12 @@ 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 +22,14 @@ 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 a02c893d54a0f7..428485c4fcc661 100644 --- a/homeassistant/components/zwave_me/entity.py +++ b/homeassistant/components/zwave_me/entity.py @@ -8,12 +8,13 @@ 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 6ab1df618cb537..4c7be298ca73ca 100644 --- a/homeassistant/components/zwave_me/fan.py +++ b/homeassistant/components/zwave_me/fan.py @@ -5,12 +5,12 @@ 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 +18,14 @@ 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/light.py b/homeassistant/components/zwave_me/light.py index f8ed397ea2550a..6faaf25d4e8951 100644 --- a/homeassistant/components/zwave_me/light.py +++ b/homeassistant/components/zwave_me/light.py @@ -9,22 +9,23 @@ 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 +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] - 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 +60,7 @@ def __init__( 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 +69,38 @@ def turn_off(self, **kwargs: Any) -> None: 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) + + 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 color is None: - brightness = kwargs.get(ATTR_BRIGHTNESS) - if brightness is None: - self.controller.zwave_api.send_command(self.device.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 cdc8b6471c1979..33e0238bf5fcd7 100644 --- a/homeassistant/components/zwave_me/lock.py +++ b/homeassistant/components/zwave_me/lock.py @@ -7,12 +7,12 @@ 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 +20,7 @@ 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 +28,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 0f12a537b42464..2b9d4bd34c6e24 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 2d6b88840f4af0..435984bdcd1616 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 @@ 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 fa9ccdfee9917a..b03f2b9ab558e9 100644 --- a/homeassistant/components/zwave_me/sensor.py +++ b/homeassistant/components/zwave_me/sensor.py @@ -13,7 +13,6 @@ SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, @@ -28,8 +27,8 @@ 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 +116,20 @@ class ZWaveMeSensorEntityDescription(SensorEntityDescription): 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 7bfbf2b2cd4ac6..8eb771aa7b12da 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 @@ 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 @@ def add_new_device(new_device): 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 26d832ca022109..9b49ec1a1d5378 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 @@ 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 @@ def add_new_device(new_device): 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_entries.py b/homeassistant/config_entries.py index ab4c2d7d7b334c..ad42d180c3d187 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -316,11 +316,11 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False): class FlowType(StrEnum): - """Flow type.""" + """Flow type supported in `next_flow` of ConfigFlowResult.""" CONFIG_FLOW = "config_flow" - # Add other flow types here as needed in the future, - # if we want to support them in the `next_flow` parameter. + OPTIONS_FLOW = "options_flow" + CONFIG_SUBENTRIES_FLOW = "config_subentries_flow" def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> None: @@ -579,6 +579,13 @@ def __setattr__(self, key: str, value: Any) -> None: 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 +632,14 @@ def supported_subentry_types(self) -> dict[str, dict[str, bool]]: ) 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 +705,9 @@ async def __async_setup_with_context( 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 +736,7 @@ async def __async_setup_with_context( 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 +752,7 @@ async def __async_setup_with_context( 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 +787,7 @@ async def __async_setup_with_context( 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 +795,7 @@ async def __async_setup_with_context( 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 +810,13 @@ async def __async_setup_with_context( 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 +834,14 @@ async def __async_setup_with_context( ) 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 +864,7 @@ async def __async_setup_with_context( 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 +879,13 @@ async def __async_setup_with_context( 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 +1039,7 @@ async def async_unload( ) 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 +1082,7 @@ async def async_remove(self, hass: HomeAssistant) -> None: 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 +1127,7 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: 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 +1148,7 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: 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 +1158,14 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: 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 +1228,7 @@ async def _async_process_on_unload(self, hass: HomeAssistant) -> None: ) 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 +1257,7 @@ def _async_process_on_state_change(self) -> None: try: func() except Exception: - _LOGGER.exception( + self.logger.exception( "Error calling on_state_change callback for %s (%s)", self.title, self.domain, @@ -1590,6 +1608,26 @@ def async_flow_removed( 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 +1666,7 @@ async def async_finish_flow( ) } ) - _LOGGER.debug( + entry.logger.debug( "Updating discovery keys for %s entry %s %s -> %s", entry.domain, unique_id, @@ -1638,6 +1676,8 @@ async def async_finish_flow( 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 +1792,10 @@ async def async_finish_flow( 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 +1905,7 @@ def __setitem__(self, entry_id: str, entry: ConfigEntry) -> None: 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 +1928,7 @@ def check_unique_id(self, entry: ConfigEntry) -> None: 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 +2318,7 @@ async def _async_scan_orphan_ignored_entries( 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 +2550,7 @@ def _async_update_entry( 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" @@ -3273,7 +3317,10 @@ def _async_set_next_flow_if_valid( 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 +3341,15 @@ def async_abort( 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, @@ -4038,7 +4094,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 75bee8f442be6a..4ebe4ddf6d0d1d 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -16,8 +16,8 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 -MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "4" +MINOR_VERSION: Final = 5 +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2) @@ -523,6 +523,7 @@ class UnitOfEnergyDistance(StrEnum): class UnitOfElectricCurrent(StrEnum): """Electric current units.""" + MICROAMPERE = "μA" MILLIAMPERE = "mA" AMPERE = "A" @@ -590,6 +591,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 1c51a5641293f3..a37d4480ea901d 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -544,8 +544,9 @@ def add_job[*_Ts]( ) -> 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 +558,14 @@ def add_job[*_Ts]( 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 +607,9 @@ def async_add_job[_R, *_Ts]( ) -> 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 f7169c38b91601..678094a3a1d65a 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -526,7 +526,7 @@ def remove(self, value: str) -> None: 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/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 51435aac0bb4dd..a520338e91629c 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -6,6 +6,7 @@ APPLICATION_CREDENTIALS = [ "aladdin_connect", "august", + "dropbox", "ekeybionyx", "electric_kiwi", "fitbit", diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 8abd999eedf908..a09bdfa4d9deda 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -133,6 +133,11 @@ "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 82e09a98b3e7dd..d981856b0e4925 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -14,6 +14,7 @@ "integration", "min_max", "mold_indicator", + "otp", "random", "statistics", "switch_as_x", @@ -142,6 +143,7 @@ "deconz", "decora_wifi", "deluge", + "denon_rs232", "denonavr", "devialet", "devolo_home_control", @@ -160,15 +162,18 @@ "downloader", "dremel_3d_printer", "drop_connect", + "dropbox", "droplet", "dsmr", "dsmr_reader", "duckdns", + "duco", "dunehd", "duotecno", "dwd_weather_warnings", "dynalite", "eafm", + "earn_e_p1", "easyenergy", "ecobee", "ecoforest", @@ -205,6 +210,7 @@ "esphome", "essent", "eufylife_ble", + "eurotronic_cometblue", "evil_genius_labs", "ezviz", "faa_delays", @@ -241,6 +247,7 @@ "frontier_silicon", "fujitsu_fglair", "fully_kiosk", + "fumis", "fyta", "garages_amsterdam", "gardena_bluetooth", @@ -305,6 +312,7 @@ "homewizard", "homeworks", "honeywell", + "honeywell_string_lights", "hr_energy_qube", "html5", "huawei_lte", @@ -365,6 +373,7 @@ "keenetic_ndms2", "kegtron", "keymitt_ble", + "kiosker", "kmtronic", "knocki", "knx", @@ -489,6 +498,7 @@ "nobo_hub", "nordpool", "notion", + "novy_cooker_hood", "nrgkick", "ntfy", "nuheat", @@ -501,6 +511,7 @@ "octoprint", "ohme", "ollama", + "omie", "omnilogic", "ondilo_ico", "onedrive", @@ -526,7 +537,6 @@ "orvibo", "osoenergy", "otbr", - "otp", "ourgroceries", "overkiz", "overseerr", @@ -544,7 +554,9 @@ "philips_js", "pi_hole", "picnic", + "picotts", "ping", + "pjlink", "plaato", "playstation_network", "plex", @@ -715,6 +727,7 @@ "technove", "tedee", "telegram_bot", + "teleinfo", "tellduslive", "teltonika", "tesla_fleet", @@ -761,6 +774,7 @@ "ukraine_alarm", "unifi", "unifi_access", + "unifi_discovery", "unifiprotect", "upb", "upcloud", @@ -780,6 +794,7 @@ "vesync", "vicare", "victron_ble", + "victron_gx", "victron_remote_monitoring", "vilfo", "vivotek", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 1d2e1847c841a6..700374cf8dac7d 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -173,6 +173,14 @@ "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 +270,10 @@ "domain": "fully_kiosk", "registered_devices": True, }, + { + "domain": "fumis", + "macaddress": "0016D0*", + }, { "domain": "fyta", "hostname": "fyta*", @@ -321,6 +333,10 @@ "hostname": "hunter*", "macaddress": "002674*", }, + { + "domain": "iaqualink", + "hostname": "iaqualink-*", + }, { "domain": "incomfort", "hostname": "rfgateway", @@ -436,6 +452,14 @@ "domain": "motion_blinds", "hostname": "connector_*", }, + { + "domain": "mystrom", + "hostname": "mystrom-*", + }, + { + "domain": "mystrom", + "registered_devices": True, + }, { "domain": "nest", "macaddress": "18B430*", @@ -1319,43 +1343,43 @@ "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 718c3745be890b..ac97ac50c71f7e 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 6ab998db816d87..13f4996430c174 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1323,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, @@ -1485,6 +1491,12 @@ "config_flow": true, "iot_class": "local_push" }, + "dropbox": { + "name": "Dropbox", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "droplet": { "name": "Droplet", "integration_type": "device", @@ -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", @@ -4717,6 +4765,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 +4910,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 +5127,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 +5293,8 @@ }, "picotts": { "name": "Pico TTS", - "integration_type": "hub", - "config_flow": false, + "integration_type": "service", + "config_flow": true, "iot_class": "local_push" }, "pilight": { @@ -5277,8 +5332,8 @@ }, "pjlink": { "name": "PJLink", - "integration_type": "hub", - "config_flow": false, + "integration_type": "device", + "config_flow": true, "iot_class": "local_polling" }, "plaato": { @@ -6079,6 +6134,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "sensereo": { + "name": "Sensereo", + "iot_standards": [ + "matter" + ] + }, "sensibo": { "name": "Sensibo", "integration_type": "hub", @@ -6931,6 +6992,12 @@ } } }, + "teleinfo": { + "name": "Teleinfo", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "telldus": { "name": "Telldus", "integrations": { @@ -7593,6 +7660,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 +8236,12 @@ "zwave" ] }, + "zunzunbee": { + "name": "Zunzunbee", + "iot_standards": [ + "zigbee" + ] + }, "zwave_js": { "name": "Z-Wave", "integration_type": "hub", @@ -8256,6 +8335,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 d7777d68ff1fb4..45b5c803fd32b9 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -341,25 +341,7 @@ "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 @@ "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 d1974f23d6e5b8..70da80846d8f5c 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -58,6 +58,16 @@ "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 50bb4f31414eda..7bce7c8cc76621 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -583,6 +583,10 @@ "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 @@ "domain": "loqed", "name": "loqed*", }, + { + "domain": "lunatone", + "properties": { + "manufacturer": "lunatone industrielle elektronik gmbh", + "type": "dali-2-*", + "uid": "*", + }, + }, { "domain": "nam", "name": "nam-*", @@ -695,6 +707,11 @@ "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 0939c31eadca8b..6d311a589e6fa4 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -24,7 +24,6 @@ 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 +213,6 @@ async def __anext__(self) -> bytes: @callback -@bind_hass def async_get_clientsession( hass: HomeAssistant, verify_ssl: bool = True, @@ -244,7 +242,6 @@ def async_get_clientsession( @callback -@bind_hass def async_create_clientsession( hass: HomeAssistant, verify_ssl: bool = True, @@ -318,7 +315,6 @@ def _async_create_clientsession( return clientsession -@bind_hass async def async_aiohttp_proxy_web( hass: HomeAssistant, request: web.BaseRequest, @@ -351,7 +347,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/collection.py b/homeassistant/helpers/collection.py index aef673cb5001a6..b1d4390cc5d5b9 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -545,13 +545,21 @@ def __init__( 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 +574,18 @@ def item_id_key(self) -> str: @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 +608,7 @@ def async_setup(self, hass: HomeAssistant) -> None: 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 810b8f40b73270..f36d8d5db9be83 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -16,8 +16,10 @@ from typing import ( TYPE_CHECKING, Any, + ClassVar, Final, Literal, + Never, Protocol, TypedDict, Unpack, @@ -93,7 +95,12 @@ NumericThresholdType, TargetSelector, ) -from .target import TargetSelection, async_extract_referenced_entity_ids +from .target import ( + TargetSelection, + TargetStateChangedData, + async_extract_referenced_entity_ids, + async_track_target_selector_state_change_event, +) from .template import Template, render_complex from .trace import ( TraceElement, @@ -284,10 +291,95 @@ async def _register_condition_platform( ) -class Condition(abc.ABC): - """Condition class.""" +class ConditionChecker(abc.ABC): + """Base class for condition checkers.""" + + 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() - _hass: HomeAssistant + +class Condition(ConditionChecker): + """Condition class.""" @classmethod async def async_validate_complete_config( @@ -323,11 +415,7 @@ async def async_validate_config( def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: """Initialize condition.""" - self._hass = hass - - @abc.abstractmethod - async def async_get_checker(self) -> ConditionChecker: - """Get the condition checker.""" + super().__init__(hass) ATTR_BEHAVIOR: Final = "behavior" @@ -337,10 +425,11 @@ async def async_get_checker(self) -> ConditionChecker: ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL = vol.Schema( { vol.Required(CONF_TARGET): cv.TARGET_FIELDS, - vol.Required(CONF_OPTIONS): { + vol.Required(CONF_OPTIONS, default={}): { vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In( [BEHAVIOR_ANY, BEHAVIOR_ALL] ), + vol.Optional(CONF_FOR): cv.positive_time_period, }, } ) @@ -350,7 +439,13 @@ class EntityConditionBase(Condition): """Base class for entity conditions.""" _domain_specs: Mapping[str, DomainSpec] + _excluded_states: Final[frozenset[str]] = frozenset( + {STATE_UNAVAILABLE, STATE_UNKNOWN} + ) _schema: vol.Schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL + # When True, indirect target expansion (via device/area/floor) skips + # entities with an entity_category. + _primary_entities_only: ClassVar[bool] = True @override @classmethod @@ -366,60 +461,176 @@ def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None: 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) - 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] - if domain_spec.value_source is None: - return entity_state.state - return entity_state.attributes.get(domain_spec.value_source) + @property + def _needs_duration_tracking(self) -> bool: + """Whether this condition needs active state change tracking for duration. - @abc.abstractmethod - def is_valid_state(self, entity_state: State) -> bool: - """Check if the state matches the expected state(s).""" + 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_get_checker(self) -> ConditionChecker: - """Get the condition checker.""" + 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 - 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) + @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) - 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) + @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 - 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 + @abc.abstractmethod + def is_valid_state(self, entity_state: State) -> bool: + """Check if the state matches the expected state(s).""" - 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 + 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 ) - referenced_entity_ids = targeted_entities.referenced.union( - targeted_entities.indirectly_referenced + 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 ) - 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 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 + ) - return test_state + 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): @@ -427,6 +638,22 @@ class EntityStateConditionBase(EntityConditionBase): _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] + if domain_spec.value_source is None: + return entity_state.state + return entity_state.attributes.get(domain_spec.value_source) + def is_valid_state(self, entity_state: State) -> bool: """Check if the state matches the expected state(s).""" return self._get_tracked_value(entity_state) in self._states @@ -444,6 +671,8 @@ def _normalize_domain_specs( def make_entity_state_condition( domain_specs: Mapping[str, DomainSpec] | str, states: str | bool | set[str | bool], + *, + primary_entities_only: bool = True, ) -> type[EntityStateConditionBase]: """Create a condition for entity state changes to specific state(s). @@ -462,6 +691,7 @@ class CustomCondition(EntityStateConditionBase): _domain_specs = specs _states = states_set + _primary_entities_only = primary_entities_only return CustomCondition @@ -569,6 +799,8 @@ def is_valid_state(self, entity_state: State) -> bool: def make_entity_numerical_condition( domain_specs: Mapping[str, DomainSpec] | str, valid_unit: str | None | UndefinedType = UNDEFINED, + *, + primary_entities_only: bool = True, ) -> type[EntityNumericalConditionBase]: """Create a condition for numerical state comparisons.""" specs = _normalize_domain_specs(domain_specs) @@ -578,6 +810,7 @@ class CustomCondition(EntityNumericalConditionBase): _domain_specs = specs _valid_unit = valid_unit + _primary_entities_only = primary_entities_only return CustomCondition @@ -707,13 +940,6 @@ class ConditionCheckParams(TypedDict, total=False): variables: TemplateVarsType -class ConditionChecker(Protocol): - """Protocol for condition checker callable with typed kwargs.""" - - def __call__(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: - """Check the condition.""" - - type ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool] type ConditionCheckerTypeOptional = Callable[ [HomeAssistant, TemplateVarsType], bool | None @@ -837,20 +1063,10 @@ async def _async_get_condition_platform( return platform, platform_module -async def _async_get_checker(condition: Condition) -> ConditionCheckerType: - new_checker = await condition.async_get_checker() - - @trace_condition_function - def checker(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: - return new_checker(variables=variables) - - return checker - - async def async_from_config( hass: HomeAssistant, config: ConfigType, -) -> ConditionCheckerTypeOptional: +) -> ConditionChecker: """Turn a condition configuration into a method. Should be run on the event loop. @@ -866,15 +1082,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 @@ -893,7 +1101,8 @@ def disabled_condition( 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) @@ -907,30 +1116,40 @@ def disabled_condition( 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 @@ -939,29 +1158,32 @@ def if_and_condition( return True - return if_and_condition - async def async_or_from_config( hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: +) -> ConditionChecker: """Create multi condition matcher using 'OR'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] + return OrConditionChecker(hass, checks) - @trace_condition_function - def if_or_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool: + +class OrConditionChecker(CompoundConditionChecker): + """Condition checker for 'or' compound conditions.""" + + @callback + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """Test or condition.""" errors = [] - for index, check in enumerate(checks): + for index, condition in enumerate(self._conditions): try: with trace_path(["conditions", str(index)]): - if check(hass, variables) is True: + if condition.async_check(**kwargs) is True: return True except ConditionError as ex: errors.append( - ConditionErrorIndex("or", index=index, total=len(checks), error=ex) + ConditionErrorIndex( + "or", index=index, total=len(self._conditions), error=ex + ) ) # Raise the errors if no check was true @@ -970,29 +1192,32 @@ def if_or_condition( return False - return if_or_condition - async def async_not_from_config( hass: HomeAssistant, config: ConfigType -) -> ConditionCheckerType: +) -> ConditionChecker: """Create multi condition matcher using 'NOT'.""" checks = [await async_from_config(hass, entry) for entry in config["conditions"]] + return NotConditionChecker(hass, checks) - @trace_condition_function - def if_not_condition( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool: + +class NotConditionChecker(CompoundConditionChecker): + """Condition checker for 'not' compound conditions.""" + + @callback + def _async_check(self, **kwargs: Unpack[ConditionCheckParams]) -> bool: """Test not condition.""" errors = [] - for index, check in enumerate(checks): + for index, condition in enumerate(self._conditions): try: with trace_path(["conditions", str(index)]): - if check(hass, variables): + if condition.async_check(**kwargs): return False except ConditionError as ex: errors.append( - ConditionErrorIndex("not", index=index, total=len(checks), error=ex) + ConditionErrorIndex( + "not", index=index, total=len(self._conditions), error=ex + ) ) # Raise the errors if no check was true @@ -1001,8 +1226,6 @@ def if_not_condition( return True - return if_not_condition - def numeric_state( hass: HomeAssistant, @@ -1159,7 +1382,6 @@ def async_numeric_state_from_config(config: ConfigType) -> ConditionCheckerType: above = config.get(CONF_ABOVE) value_template = config.get(CONF_VALUE_TEMPLATE) - @trace_condition_function def if_numeric_state( hass: HomeAssistant, variables: TemplateVarsType = None ) -> bool: @@ -1278,7 +1500,6 @@ def state_from_config(config: ConfigType) -> ConditionCheckerType: if not isinstance(req_states, list): req_states = [req_states] - @trace_condition_function def if_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Test if condition.""" errors = [] @@ -1340,7 +1561,6 @@ def async_template_from_config(config: ConfigType) -> ConditionCheckerType: """Wrap action method with state based condition.""" value_template = cast(Template, config.get(CONF_VALUE_TEMPLATE)) - @trace_condition_function def template_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Validate template based if-condition.""" return async_template(hass, value_template, variables) @@ -1384,7 +1604,7 @@ def time( after = datetime.strptime(after_entity.state, "%H:%M:%S").time() elif ( after_entity.attributes.get(ATTR_DEVICE_CLASS) - == SensorDeviceClass.TIMESTAMP + in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME) ) and after_entity.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -1414,7 +1634,7 @@ def time( return False elif ( before_entity.attributes.get(ATTR_DEVICE_CLASS) - == SensorDeviceClass.TIMESTAMP + in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME) ) and before_entity.state not in ( STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -1453,7 +1673,6 @@ def time_from_config(config: ConfigType) -> ConditionCheckerType: after = config.get(CONF_AFTER) weekday = config.get(CONF_WEEKDAY) - @trace_condition_function def time_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: """Validate time based if-condition.""" return time(hass, before, after, weekday) @@ -1467,7 +1686,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 ( @@ -1556,40 +1774,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) + + +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 check_conditions(variables: TemplateVarsType = None) -> bool: + 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_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index c5bce5779c5437..404c47e21ffb83 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -804,6 +804,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 36424a06b2bb1b..6851756c9e4377 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -759,15 +759,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) @@ -868,11 +860,16 @@ def url( ) -> str: """Validate an URL.""" url_in = str(value) + parsed = urlparse(url_in) - if urlparse(url_in).scheme in _schema_list: - return cast(str, vol.Schema(vol.Url())(url_in)) + if parsed.scheme not in _schema_list: + raise vol.Invalid("invalid url") - raise vol.Invalid("invalid url") + try: + _port = parsed.port + except ValueError as err: + raise vol.Invalid("invalid url") from err + return cast(str, vol.Schema(vol.Url())(url_in)) def configuration_url(value: Any) -> str: diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index 7c1b5ac4a641f5..341c645a681146 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -13,7 +13,6 @@ 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 +35,6 @@ class DiscoveryDict(TypedDict): @core.callback -@bind_hass def async_listen( hass: core.HomeAssistant, service: str, @@ -62,7 +60,6 @@ def _async_discovery_event_listener(discovered: DiscoveryDict) -> None: ) -@bind_hass def discover( hass: core.HomeAssistant, service: str, @@ -77,7 +74,6 @@ def discover( ) -@bind_hass async def async_discover( hass: core.HomeAssistant, service: str, @@ -100,7 +96,6 @@ async def async_discover( ) -@bind_hass def async_listen_platform( hass: core.HomeAssistant, component: str, @@ -127,7 +122,6 @@ def _async_discovery_platform_listener(discovered: DiscoveryDict) -> None: ) -@bind_hass def load_platform( hass: core.HomeAssistant, component: Platform | str, @@ -142,7 +136,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 fd41c7ffb44810..ac5974c36e6ad0 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -8,7 +8,6 @@ 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 +36,6 @@ def from_json_dict(cls, json_dict: dict[str, Any]) -> Self: 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 8eda564e7cb04e..91164d4b7a73a0 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -15,7 +15,6 @@ 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 +35,18 @@ @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 +85,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 +92,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 +119,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 +171,6 @@ def _generate_job[*_Ts]( @overload @callback -@bind_hass def async_dispatcher_send[*_Ts]( hass: HomeAssistant, signal: SignalType[*_Ts], *args: *_Ts ) -> None: ... @@ -189,12 +178,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 +203,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 d9aca3669cebd2..8ada64d864eaa3 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -50,7 +50,7 @@ ) 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. @@ -1040,9 +1039,14 @@ def _async_write_ha_state_from_call_soon_threadsafe(self) -> None: self._async_verify_state_writable() self._async_write_ha_state() + @final @callback def async_write_ha_state(self) -> None: - """Write the state to the state machine.""" + """Write the state to the state machine. + + Note: Integrations which need to customize state write should + override _async_write_ha_state, not this method. + """ if not self.hass or not self._verified_state_writable: self._async_verify_state_writable() if self.hass.loop_thread_id != threading.get_ident(): diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index ca46be3d93462d..e524c2cecaa831 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -3,7 +3,7 @@ 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 +17,7 @@ EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import ( + EntityServiceResponse, Event, HassJobType, HomeAssistant, @@ -29,7 +30,7 @@ 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 +42,6 @@ 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 +96,7 @@ def __init__( ] = {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 +107,11 @@ def entities(self) -> Iterable[_EntityT]: 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 +242,37 @@ def async_register_entity_service( 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 9ad5fbd5f61a78..3ef6dfac39c519 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -787,7 +787,7 @@ def _entity_id_already_exists(self, entity_id: str) -> tuple[bool, bool]: 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 +822,41 @@ async def _async_add_entity( # 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/event.py b/homeassistant/helpers/event.py index 03c699168ef563..957185ecf7613a 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -36,8 +36,7 @@ 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 +198,6 @@ def remove() -> None: @callback -@bind_hass def async_track_state_change( hass: HomeAssistant, entity_ids: str | Iterable[str], @@ -305,7 +303,6 @@ def state_change_listener(event: Event[EventStateChangedData]) -> None: 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 +381,6 @@ def _async_state_filter[_StateEventDataT: EventStateEventData]( ) -@bind_hass def _async_track_state_change_event( hass: HomeAssistant, entity_ids: str | Iterable[str], @@ -537,7 +533,6 @@ def _async_entity_registry_updated_filter( ) -@bind_hass @callback def async_track_entity_registry_updated_event( hass: HomeAssistant, @@ -649,7 +644,6 @@ def _async_domain_added_filter( ) -@bind_hass def async_track_state_added_domain( hass: HomeAssistant, domains: str | Iterable[str], @@ -670,7 +664,6 @@ def async_track_state_added_domain( ) -@bind_hass def _async_track_state_added_domain( hass: HomeAssistant, domains: str | Iterable[str], @@ -707,7 +700,6 @@ def _async_domain_removed_filter( ) -@bind_hass def async_track_state_removed_domain( hass: HomeAssistant, domains: str | Iterable[str], @@ -863,7 +855,6 @@ def _setup_all_listener(self) -> None: @callback -@bind_hass def async_track_state_change_filtered( hass: HomeAssistant, track_states: TrackStates, @@ -894,7 +885,6 @@ def async_track_state_change_filtered( @callback -@bind_hass def async_track_template( hass: HomeAssistant, template: Template, @@ -1000,14 +990,6 @@ def __init__( 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 +1321,6 @@ def _refresh( @callback -@bind_hass def async_track_template_result( hass: HomeAssistant, track_templates: Sequence[TrackTemplate], @@ -1392,7 +1373,6 @@ def async_track_template_result( @callback -@bind_hass def async_track_same_state( hass: HomeAssistant, period: timedelta, @@ -1460,7 +1440,6 @@ def state_for_cancel_listener(event: Event[EventStateChangedData]) -> None: @callback -@bind_hass def async_track_point_in_time( hass: HomeAssistant, action: HassJob[[datetime], Coroutine[Any, Any, None] | None] @@ -1540,7 +1519,6 @@ def async_cancel(self) -> None: @callback -@bind_hass def async_track_point_in_utc_time( hass: HomeAssistant, action: HassJob[[datetime], Coroutine[Any, Any, None] | None] @@ -1575,7 +1553,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 +1572,6 @@ def async_call_at( @callback -@bind_hass def async_call_later( hass: HomeAssistant, delay: float | timedelta, @@ -1675,7 +1651,6 @@ def async_cancel(self) -> None: @callback -@bind_hass def async_track_time_interval( hass: HomeAssistant, action: Callable[[datetime], Coroutine[Any, Any, None] | None], @@ -1761,7 +1736,6 @@ def _handle_config_event(self, _event: Any) -> None: @callback -@bind_hass def async_track_sunrise( hass: HomeAssistant, action: Callable[[], None], offset: timedelta | None = None ) -> CALLBACK_TYPE: @@ -1777,7 +1751,6 @@ def async_track_sunrise( @callback -@bind_hass def async_track_sunset( hass: HomeAssistant, action: Callable[[], None], offset: timedelta | None = None ) -> CALLBACK_TYPE: @@ -1853,7 +1826,6 @@ def async_cancel(self) -> None: @callback -@bind_hass def async_track_utc_time_change( hass: HomeAssistant, action: Callable[[datetime], Coroutine[Any, Any, None] | None], @@ -1901,7 +1873,6 @@ def 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/httpx_client.py b/homeassistant/helpers/httpx_client.py index d253c3377aa033..469f1223bf02fe 100644 --- a/homeassistant/helpers/httpx_client.py +++ b/homeassistant/helpers/httpx_client.py @@ -14,7 +14,6 @@ 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 +43,6 @@ @callback -@bind_hass def get_async_client( hass: HomeAssistant, verify_ssl: bool = True, diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index 4ded7444989290..39c30cdca58a76 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -17,7 +17,6 @@ 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 +152,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 85f990535571d0..d9a3e47c6e0d27 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -23,7 +23,6 @@ ) 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 +71,6 @@ @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 +88,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 +102,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 +770,6 @@ def async_match_targets( # noqa: C901 @callback -@bind_hass def async_match_states( hass: HomeAssistant, name: str | None = None, @@ -1181,17 +1176,11 @@ async def _run_then_background(self, task: asyncio.Task[Any]) -> None: 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 +1429,16 @@ def async_set_speech_slots(self, speech_slots: dict[str, Any]) -> None: 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 ce12d1f19da760..46686ee7f110a7 100644 --- a/homeassistant/helpers/issue_registry.py +++ b/homeassistant/helpers/issue_registry.py @@ -29,6 +29,13 @@ 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/llm.py b/homeassistant/helpers/llm.py index c9ca479df8ea91..827a018f9294f0 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -1160,6 +1160,26 @@ async def async_call( 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 +1193,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 +1226,62 @@ async def async_call( # 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/network.py b/homeassistant/helpers/network.py index 6f4aadaf7868ac..51864d8b504ea6 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -12,7 +12,6 @@ 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 +26,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 +37,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 +111,6 @@ def cloud_url() -> str | None: return False -@bind_hass def get_url( hass: HomeAssistant, *, @@ -229,7 +225,6 @@ def _get_request_host() -> str | None: return host -@bind_hass def _get_internal_url( hass: HomeAssistant, *, @@ -267,7 +262,6 @@ def _get_internal_url( raise NoURLAvailableError -@bind_hass def _get_external_url( hass: HomeAssistant, *, @@ -312,7 +306,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/script.py b/homeassistant/helpers/script.py index 98f23ecd47e71d..a8153a4a780e00 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -92,7 +92,7 @@ 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 +137,7 @@ 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 +514,7 @@ async def _async_step(self, log_exceptions: bool) -> None: 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 +532,10 @@ async def _async_step(self, log_exceptions: bool) -> None: 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 +558,11 @@ async def async_stop(self) -> None: 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 +593,9 @@ def _handle_exception( 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 +693,12 @@ async def _async_step_sequence(self) -> None: ### 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 +713,7 @@ def traced_test_conditions( 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 +764,7 @@ async def _async_step_condition(self) -> None: 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 +1367,9 @@ async def _async_stop_scripts_after_shutdown( """Stop running Script objects started after shutdown.""" hass.data[DATA_NEW_SCRIPT_RUNS_NOT_ALLOWED] = None running_scripts = [ - script for script in hass.data[DATA_SCRIPTS] if script["instance"].is_running + script + for script in hass.data[DATA_SCRIPTS].values() + if script["instance"].is_running ] if running_scripts: names = ", ".join([script["instance"].name for script in running_scripts]) @@ -1377,7 +1388,7 @@ async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) -> running_scripts = [ script - for script in hass.data[DATA_SCRIPTS] + for script in hass.data[DATA_SCRIPTS].values() if script["instance"].is_running and script["started_before_shutdown"] ] if running_scripts: @@ -1413,12 +1424,12 @@ def _referenced_extract_ids(data: Any, key: str, found: set[str]) -> None: class _ChooseData(TypedDict): - choices: list[tuple[list[ConditionCheckerTypeOptional], Script]] + choices: list[tuple[list[ConditionChecker], Script]] default: Script | None class _IfData(TypedDict): - if_conditions: list[ConditionCheckerTypeOptional] + if_conditions: list[ConditionChecker] if_then: Script if_else: Script | None @@ -1458,16 +1469,17 @@ def __init__( enabled attribute is only used for non-top-level scripts. """ - if not (all_scripts := hass.data.get(DATA_SCRIPTS)): - all_scripts = hass.data[DATA_SCRIPTS] = [] + if (all_scripts := hass.data.get(DATA_SCRIPTS)) is None: + all_scripts = hass.data[DATA_SCRIPTS] = {} hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, partial(_async_stop_scripts_at_shutdown, hass) ) self.top_level = top_level if top_level: - all_scripts.append( - {"instance": self, "started_before_shutdown": not hass.is_stopping} - ) + all_scripts[id(self)] = { + "instance": self, + "started_before_shutdown": not hass.is_stopping, + } if DATA_SCRIPT_BREAKPOINTS not in hass.data: hass.data[DATA_SCRIPT_BREAKPOINTS] = {} @@ -1495,16 +1507,24 @@ def __init__( self._max_exceeded = max_exceeded if script_mode == SCRIPT_MODE_QUEUED: self._queue_lck = asyncio.Lock() - self._config_cache: dict[ - frozenset[tuple[str, str]], ConditionCheckerTypeOptional - ] = {} + self._condition_cache: dict[frozenset[tuple[str, str]], ConditionChecker] = {} self._repeat_script: dict[int, Script] = {} self._choose_data: dict[int, _ChooseData] = {} self._if_data: dict[int, _IfData] = {} self._parallel_scripts: dict[int, list[Script]] = {} self._sequence_scripts: dict[int, Script] = {} + self._unloaded = False self.variables = variables + def __del__(self) -> None: + """Clean up when the script is deleted.""" + if self._unloaded: + return + try: + self._async_unload() + except Exception: + _LOGGER.exception("Error while unloading script") + @property def change_listener(self) -> Callable[..., Any] | None: """Return the change_listener.""" @@ -1769,17 +1789,23 @@ async def async_run( 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 +1915,73 @@ async def async_stop( 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/selector.py b/homeassistant/helpers/selector.py index 3194de03dc559e..605ffefbd0a575 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -422,6 +422,69 @@ def __call__(self, data: Any) -> str: 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 +1834,34 @@ def __call__(self, data: Any) -> Any: 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 +1947,7 @@ class TargetSelectorConfig(BaseSelectorConfig, total=False): entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig] + primary_entities_only: bool @SELECTORS.register("target") @@ -1877,6 +1969,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/service.py b/homeassistant/helpers/service.py index d7484f214fb4cf..63dc488803af96 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -3,7 +3,7 @@ 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 +48,7 @@ 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 +252,6 @@ def log_missing( super().log_missing(missing_entities, logger or _LOGGER) -@bind_hass def call_from_config( hass: HomeAssistant, config: ConfigType, @@ -267,7 +266,6 @@ def call_from_config( ).result() -@bind_hass async def async_call_from_config( hass: HomeAssistant, config: ConfigType, @@ -290,7 +288,6 @@ async def async_call_from_config( @callback -@bind_hass def async_prepare_call_from_config( hass: HomeAssistant, config: ConfigType, @@ -452,7 +449,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 +528,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 +642,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 +673,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 +718,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 +748,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 +802,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: + return entities + + +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 = entities[0] - single_response = await _handle_entity_call( - hass, entity, func, data, call.context - ) + entity, coro = entity_calls[0] + single_result = await entity.async_request_call(_with_context(entity, coro)) 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) + # 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_response} if return_response else None + return {entity.entity_id: single_result} - # Use asyncio.gather here to ensure the returned results - # are in the same order as the entities list + 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 +922,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 +1021,6 @@ async def _async_admin_handler( return None -@bind_hass @callback def async_register_admin_service( hass: HomeAssistant, @@ -1123,7 +1199,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 +1235,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 +1309,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 +1332,46 @@ def get_entities() -> dict[str, Entity]: ) +@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 5a9d50baaec617..9544090cd8d0da 100644 --- a/homeassistant/helpers/service_info/esphome.py +++ b/homeassistant/helpers/service_info/esphome.py @@ -22,5 +22,5 @@ def socket_path(self) -> str: """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 4a4b9bead47e58..6fd2a384c0e197 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -6,7 +6,6 @@ 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 @@ @callback -@bind_hass def async_register_signal_handling(hass: HomeAssistant) -> None: """Register system signal handler for core.""" diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index dac2e5832f6347..e192f2b7087b81 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -9,7 +9,6 @@ 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 +50,6 @@ def wrapper(func: _FuncType[_Coro[_T] | _U]) -> _FuncType[_Coro[_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 +58,6 @@ def wrapped(hass: HomeAssistant) -> _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/state.py b/homeassistant/helpers/state.py index 70f64d5296a224..de8f7c3ec832f8 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -21,12 +21,11 @@ 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 d651f6c36c4347..5b251860d4ad48 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -29,7 +29,6 @@ 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 +48,6 @@ MANAGER_CLEANUP_DELAY = 60 -@bind_hass async def async_migrator[_T: Mapping[str, Any] | Sequence[Any]]( hass: HomeAssistant, old_path: str, @@ -226,7 +224,6 @@ def _initialize_files(self) -> None: 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 1c35f45d7133e2..85e64a75618966 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -8,7 +8,6 @@ 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 +25,6 @@ @callback -@bind_hass def get_astral_location( hass: HomeAssistant, ) -> tuple[astral.location.Location, astral.Elevation]: @@ -51,7 +49,6 @@ def get_astral_location( @callback -@bind_hass def get_astral_event_next( hass: HomeAssistant, event: str, @@ -109,7 +106,6 @@ def get_location_astral_event_next( @callback -@bind_hass def get_astral_event_date( hass: HomeAssistant, event: str, @@ -136,7 +132,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 20da2ec6d65492..e32e47b0b031d3 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -10,7 +10,6 @@ from homeassistant.const import __version__ as current_version from homeassistant.core import HomeAssistant -from homeassistant.loader import bind_hass from homeassistant.util.package import is_docker_env, is_virtual_env from homeassistant.util.system_info import is_official_image @@ -50,23 +49,22 @@ def _read_arch_file() -> str: cached_get_user = cache(getuser) -@bind_hass async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: """Return info about the system.""" is_hassio_ = is_hassio(hass) info_object = { - "installation_type": "Unknown", - "version": current_version, + "arch": platform.machine(), "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), + "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: diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index 334b7147e01b0c..6bcf3de76f330a 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -147,9 +147,22 @@ def log_missing(self, missing_entities: set[str], logger: Logger) -> None: 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 +230,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 +260,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 +283,14 @@ def __init__( 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 +309,10 @@ def _handle_entities_update(self, tracked_entities: set[str]) -> None: 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 +357,32 @@ def __init__( 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 +410,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/template/__init__.py b/homeassistant/helpers/template/__init__.py index 22a476fb941e29..fb2aeb5b03c7f7 100644 --- a/homeassistant/helpers/template/__init__.py +++ b/homeassistant/helpers/template/__init__.py @@ -5,69 +5,30 @@ 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 +37,19 @@ 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 +72,11 @@ # 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 +251,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 +270,6 @@ def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: @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 +597,6 @@ def __repr__(self) -> str: 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 "